From f59e1831b783fdb96428c1eeefb69023a7cad980 Mon Sep 17 00:00:00 2001
From: Arkadi Koifman <76536506+ArkadiK94@users.noreply.github.com>
Date: Sun, 15 Sep 2024 18:19:08 +0300
Subject: [PATCH] feat: vertical carousel option (#948)
* feat: add direction and maxSlideHeight props for adjusting the carousel to be vertical
why: 'direction' prop for changing the direction, 'maxSlideHeight' for limiting the container
of the carousel
how: add these two props and change styles accordingly
* chore: add directionSig to context
* docs: add the vertical-direction section to docs
why: so users could see that it is possible to make it vertical
* feat: add the scrollable option for the vertical carousel
how: check for see if it is a vertical carousel and if so adjust the scrolling direction
in the scroller component
* refactor: make the scroller component checks cleaner
* docs: add the needed props for vertical carousel under API in docs
* test: add test for swiping up vertical carousel
* chore: add changeset file
* chore: remove changeset file
why: no need here
* fix: some minor fixes
* docs: place the vertical direction section in right way
* fix: change the slide offsetTop of slide in vertical carousel when touch event triggered
why: the index was not updated as needed
* test: add touch screen test for swiping vertical carousel
* test: add touchEvent tests for vertical carousel
what: one test for swiping and one for check that the slide index updates
---
.../headless/carousel/examples/carousel.css | 3 +-
.../carousel/examples/vertical-direction.tsx | 27 ++++
.../routes/docs/headless/carousel/index.mdx | 25 +++-
.../src/components/carousel/carousel.css | 6 +-
.../src/components/carousel/carousel.test.ts | 119 ++++++++++++++++++
.../src/components/carousel/context.ts | 1 +
.../src/components/carousel/root.tsx | 18 +++
.../src/components/carousel/scroller.tsx | 55 +++++---
8 files changed, 230 insertions(+), 24 deletions(-)
create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx
diff --git a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css
index 05e3ad931..ce8bc10a9 100644
--- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css
+++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css
@@ -9,7 +9,7 @@
.carousel-slide {
border: 2px dotted hsl(var(--primary));
min-height: 10rem;
- margin-top: 0.5rem;
+ -webkit-user-select: none; /* support for Safari */
user-select: none;
}
@@ -24,6 +24,7 @@
display: flex;
justify-content: space-between;
border: 2px dotted hsl(var(--accent));
+ margin-bottom: 0.5rem;
}
.carousel-buttons button {
diff --git a/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx b/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx
new file mode 100644
index 000000000..03efc3907
--- /dev/null
+++ b/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx
@@ -0,0 +1,27 @@
+import { component$, useStyles$ } from '@builder.io/qwik';
+import { Carousel } from '@qwik-ui/headless';
+
+export default component$(() => {
+ useStyles$(styles);
+
+ const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink'];
+
+ return (
+
+
+ Prev
+ Next
+
+
+ {colors.map((color) => (
+
+ {color}
+
+ ))}
+
+
+ );
+});
+
+// internal
+import styles from './carousel.css?inline';
diff --git a/apps/website/src/routes/docs/headless/carousel/index.mdx b/apps/website/src/routes/docs/headless/carousel/index.mdx
index 632ff9d46..0f3e9ff17 100644
--- a/apps/website/src/routes/docs/headless/carousel/index.mdx
+++ b/apps/website/src/routes/docs/headless/carousel/index.mdx
@@ -50,9 +50,11 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS
[data-qui-carousel-scroller] {
overflow: hidden;
display: flex;
+ flex-direction: var(--direction);
gap: var(--gap);
+ max-height: var(--max-slide-height);
/* for mobile & scroll-snap-start */
- scroll-snap-type: x mandatory;
+ scroll-snap-type: both mandatory;
}
[data-qui-carousel-slide] {
@@ -67,7 +69,7 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS
@media (pointer: coarse) {
[data-qui-carousel-scroller][data-draggable] {
- overflow-x: scroll;
+ overflow: scroll;
}
/* make sure snap align is added after initial index animation */
@@ -140,6 +142,13 @@ To change this, use the `flex-basis` CSS property on the `` co
+### Vertical Direction
+
+Qwik UI supports vertical carousels.
+Set the `direction` prop to `column ` and define `maxSlideHeight` prop in px, for making the vertical carousel.
+
+
+
### No Scroll
Qwik UI supports carousels without a scroller, which can be useful for conditional slide carousels.
@@ -318,5 +327,17 @@ In the above example, we also use the headless progress component to show the pr
type: 'number',
description: 'Time in milliseconds before the next slide plays during autoplay.',
},
+ {
+ name: 'direction',
+ type: 'union',
+ description:
+ 'Change the direction of the carousel, for it to be veritical define the maxSlideHeight prop as well.',
+ info: '"row" | "column"',
+ },
+ {
+ name: 'maxSlideHeight',
+ type: 'number',
+ description: 'Write the height of the longest slide.',
+ },
]}
/>
diff --git a/packages/kit-headless/src/components/carousel/carousel.css b/packages/kit-headless/src/components/carousel/carousel.css
index 91aa581ef..4017377e8 100644
--- a/packages/kit-headless/src/components/carousel/carousel.css
+++ b/packages/kit-headless/src/components/carousel/carousel.css
@@ -2,9 +2,11 @@
[data-qui-carousel-scroller] {
overflow: hidden;
display: flex;
+ flex-direction: var(--direction);
gap: var(--gap);
+ max-height: var(--max-slide-height);
/* for mobile & scroll-snap-start */
- scroll-snap-type: x mandatory;
+ scroll-snap-type: both mandatory;
}
[data-qui-carousel-slide] {
@@ -19,7 +21,7 @@
@media (pointer: coarse) {
[data-qui-carousel-scroller][data-draggable] {
- overflow-x: scroll;
+ overflow: scroll;
}
/* make sure snap align is added after initial index animation */
diff --git a/packages/kit-headless/src/components/carousel/carousel.test.ts b/packages/kit-headless/src/components/carousel/carousel.test.ts
index c43c83881..8a8e165fd 100644
--- a/packages/kit-headless/src/components/carousel/carousel.test.ts
+++ b/packages/kit-headless/src/components/carousel/carousel.test.ts
@@ -444,6 +444,97 @@ test.describe('Mobile / Touch Behavior', () => {
expect(Math.abs(secondSlideBox.x - scrollerBox.x)).toBeLessThan(1); // Allow 1px tolerance
});
+ test(`GIVEN a mobile vertical carousel
+ WHEN swiping to the next slide
+ Then the next slide should snap to the top side of the scroller`, async ({
+ page,
+ }) => {
+ const { driver: d } = await setup(page, 'vertical-direction');
+
+ await expect(d.getSlideAt(0)).toHaveAttribute('data-active');
+ const boundingBox = await d.getSlideBoundingBoxAt(0);
+ const cdpSession = await page.context().newCDPSession(page);
+
+ const startY = boundingBox.y + boundingBox.height * 0.8;
+ const endY = boundingBox.y;
+ const x = boundingBox.x + boundingBox.width / 2;
+
+ // touch events
+ await page.touchscreen.tap(x, startY);
+
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{ x, y: startY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [{ x, y: endY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [{ x, y: startY }],
+ });
+
+ await page.touchscreen.tap(x, endY);
+ await page.touchscreen.tap(x, startY); // tap the slide to make it visible
+ await expect(d.getSlideAt(1)).toBeVisible();
+
+ await cdpSession.detach();
+ const scrollerBox = await d.getScrollerBoundingBox();
+ const secondSlideBox = await d.getSlideBoundingBoxAt(1);
+
+ expect(Math.abs(secondSlideBox.y - scrollerBox.y)).toBeLessThan(1); // Allow 1px tolerance
+ });
+
+ test(`GIVEN a mobile vertical carousel
+ WHEN swiping two times to the next slide and clicking next button
+ Then the third slide should be visible`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'vertical-direction');
+
+ await expect(d.getSlideAt(0)).toHaveAttribute('data-active');
+ const boundingBox = await d.getSlideBoundingBoxAt(0);
+ const cdpSession = await page.context().newCDPSession(page);
+
+ const startY = boundingBox.y + boundingBox.height * 0.99;
+ const endY = boundingBox.y;
+ const x = boundingBox.x + boundingBox.width / 2;
+
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{ x, y: startY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [{ x, y: endY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [{ x, y: startY }],
+ });
+ await expect(d.getSlideAt(1)).toBeVisible();
+
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [{ x, y: startY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [{ x, y: endY }],
+ });
+ await cdpSession.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [{ x, y: startY }],
+ });
+
+ await cdpSession.detach();
+
+ await expect(d.getSlideAt(2)).toBeVisible();
+
+ await d.getNextButton().tap();
+
+ expect(d.getSlideAt(3)).toHaveAttribute('data-active');
+ });
+
test(`GIVEN a mobile carousel
WHEN tapping the next button
THEN the next slide should snap to the left side of the scroller`, async ({
@@ -865,6 +956,34 @@ test.describe('State', () => {
await expect(progressBar).toHaveAttribute('aria-valuetext', '17%');
});
+
+ test(`GIVEN a carousel with direction column and max slide height declared
+ WHEN the swipe up or down
+ THEN the attribute should move to the right slide
+`, async ({ page }) => {
+ const { driver: d } = await setup(page, 'vertical-direction');
+ d;
+
+ const visibleSlide = d.getSlideAt(0);
+
+ const slideBox = await visibleSlide.boundingBox();
+
+ if (slideBox) {
+ const startX = slideBox.x + slideBox.width / 2;
+ const startY = slideBox.y + slideBox.height / 2;
+
+ // swipe up from the middle of the visible slide
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+ await page.mouse.move(startX, -startY, { steps: 10 });
+
+ // finish the swiping and move the mouse back
+ await page.mouse.up();
+ await page.mouse.move(startX, startY, { steps: 10 });
+ }
+ // checking that the slide changed
+ expect(d.getSlideAt(0)).not.toHaveAttribute('data-active');
+ });
});
test.describe('Stepper', () => {
diff --git a/packages/kit-headless/src/components/carousel/context.ts b/packages/kit-headless/src/components/carousel/context.ts
index cb47ad33d..53cfb2d07 100644
--- a/packages/kit-headless/src/components/carousel/context.ts
+++ b/packages/kit-headless/src/components/carousel/context.ts
@@ -24,6 +24,7 @@ export type CarouselContext = {
alignSig: Signal<'start' | 'center' | 'end'>;
isLoopSig: Signal;
autoPlayIntervalMsSig: Signal;
+ directionSig: Signal<'row' | 'column'>;
startIndex: number | undefined;
isStepInteractionSig: Signal;
};
diff --git a/packages/kit-headless/src/components/carousel/root.tsx b/packages/kit-headless/src/components/carousel/root.tsx
index 6e8229581..630099028 100644
--- a/packages/kit-headless/src/components/carousel/root.tsx
+++ b/packages/kit-headless/src/components/carousel/root.tsx
@@ -56,6 +56,11 @@ export type CarouselRootProps = PropsOf<'div'> & {
/** @internal Whether this carousel has a title */
_isTitle?: boolean;
+ /** The carousel's orientation */
+ direction?: 'row' | 'column';
+
+ /** The slider height */
+ maxSlideHeight?: number | undefined;
/** Allows the user to navigate steps when interacting with the stepper */
stepInteraction?: boolean;
};
@@ -84,6 +89,7 @@ export const CarouselBase = component$(
startIndex ?? 0,
);
const isScrollerSig = useSignal(false);
+ const directionSig = useSignal(() => props.direction ?? 'row');
const isAutoplaySig = useBoundSignal(givenAutoplaySig, false);
const getInitialProgress = () => {
@@ -98,6 +104,7 @@ export const CarouselBase = component$(
const alignSig = useComputed$(() => props.align ?? 'start');
const isLoopSig = useComputed$(() => props.loop ?? false);
const autoPlayIntervalMsSig = useComputed$(() => props.autoPlayIntervalMs ?? 0);
+ const maxSlideHeight = useComputed$(() => props.maxSlideHeight ?? undefined);
const progressSig = useBoundSignal(givenProgressSig, getInitialProgress());
const isStepInteractionSig = useComputed$(() => props.stepInteraction ?? false);
@@ -122,6 +129,7 @@ export const CarouselBase = component$(
alignSig,
isLoopSig,
autoPlayIntervalMsSig,
+ directionSig,
startIndex,
isStepInteractionSig,
};
@@ -130,6 +138,14 @@ export const CarouselBase = component$(
useContextProvider(carouselContextId, context);
+ // Max Height needed for making vertical carousel
+ useTask$(({ track }) => {
+ track(() => maxSlideHeight.value);
+ if (!maxSlideHeight.value) {
+ directionSig.value = 'row';
+ }
+ });
+
useTask$(({ track }) => {
if (!givenProgressSig) return;
track(() => currentIndexSig.value);
@@ -155,6 +171,8 @@ export const CarouselBase = component$(
'--slides-per-view': slidesPerViewSig.value,
'--gap': `${gapSig.value}px`,
'--scroll-snap-align': alignSig.value,
+ '--direction': directionSig.value,
+ '--max-slide-height': `${maxSlideHeight.value}px`,
}}
>
diff --git a/packages/kit-headless/src/components/carousel/scroller.tsx b/packages/kit-headless/src/components/carousel/scroller.tsx
index 8fff99e2b..9f7a9cc62 100644
--- a/packages/kit-headless/src/components/carousel/scroller.tsx
+++ b/packages/kit-headless/src/components/carousel/scroller.tsx
@@ -18,13 +18,14 @@ type CarouselContainerProps = PropsOf<'div'>;
export const CarouselScroller = component$((props: CarouselContainerProps) => {
const context = useContext(carouselContextId);
useStyles$(styles);
- const startXSig = useSignal();
- const scrollLeftSig = useSignal(0);
+ const startPositionSig = useSignal();
+ const scrollInDirectionSig = useSignal(0);
const isMouseDownSig = useSignal(false);
const isMouseMovingSig = useSignal(false);
const isTouchDeviceSig = useSignal(false);
const isTouchMovingSig = useSignal(true);
const isTouchStartSig = useSignal(false);
+ const isHorizontal = context.directionSig.value === 'row';
useTask$(() => {
context.isScrollerSig.value = true;
@@ -37,29 +38,36 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
let position = 0;
for (let i = 0; i < index; i++) {
if (slides[i].value) {
- position += slides[i].value.getBoundingClientRect().width + context.gapSig.value;
+ position +=
+ slides[i].value.getBoundingClientRect()[isHorizontal ? 'width' : 'height'] +
+ context.gapSig.value;
}
}
-
const alignment = context.alignSig.value;
if (alignment === 'center') {
- position -=
- (container.clientWidth - slides[index].value.getBoundingClientRect().width) / 2;
+ position -= isHorizontal
+ ? (container.clientWidth - slides[index].value.getBoundingClientRect().width) / 2
+ : (container.clientHeight - slides[index].value.getBoundingClientRect().height) /
+ 2;
} else if (alignment === 'end') {
- position -=
- container.clientWidth - slides[index].value.getBoundingClientRect().width;
+ position -= isHorizontal
+ ? container.clientWidth - slides[index].value.getBoundingClientRect().width
+ : container.clientHeight - slides[index].value.getBoundingClientRect().height;
}
return Math.max(0, position);
});
const handleMouseMove$ = $((e: MouseEvent) => {
- if (!isMouseDownSig.value || startXSig.value === undefined) return;
+ if (!isMouseDownSig.value || startPositionSig.value === undefined) return;
if (!context.scrollerRef.value) return;
- const x = e.pageX - context.scrollerRef.value.offsetLeft;
+ const position = isHorizontal
+ ? e.pageX - context.scrollerRef.value.offsetLeft
+ : e.pageY - context.scrollerRef.value.offsetTop;
const dragSpeed = 1.75;
- const walk = (x - startXSig.value) * dragSpeed;
- context.scrollerRef.value.scrollLeft = scrollLeftSig.value - walk;
+ const walk = (position - startPositionSig.value) * dragSpeed;
+ context.scrollerRef.value[isHorizontal ? 'scrollLeft' : 'scrollTop'] =
+ scrollInDirectionSig.value - walk;
isMouseMovingSig.value = true;
});
@@ -70,14 +78,15 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
const container = context.scrollerRef.value;
const slides = context.slideRefsArray.value;
- const containerScrollLeft = container.scrollLeft;
+ const containerScrollInDirection =
+ container[isHorizontal ? 'scrollLeft' : 'scrollTop'];
let closestIndex = 0;
let minDistance = Infinity;
for (let i = 0; i < slides.length; i++) {
const slidePosition = await getSlidePosition$(i);
- const distance = Math.abs(containerScrollLeft - slidePosition);
+ const distance = Math.abs(containerScrollInDirection - slidePosition);
if (distance < minDistance) {
closestIndex = i;
minDistance = distance;
@@ -88,6 +97,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
container.scrollTo({
left: dragSnapPosition,
+ top: dragSnapPosition,
behavior: 'smooth',
});
@@ -102,8 +112,11 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
}
isMouseDownSig.value = true;
- startXSig.value = e.pageX - context.scrollerRef.value.offsetLeft;
- scrollLeftSig.value = context.scrollerRef.value.scrollLeft;
+ startPositionSig.value = isHorizontal
+ ? e.pageX - context.scrollerRef.value.offsetLeft
+ : e.pageY - context.scrollerRef.value.offsetTop;
+ scrollInDirectionSig.value =
+ context.scrollerRef.value[isHorizontal ? 'scrollLeft' : 'scrollTop'];
window.addEventListener('mousemove', handleMouseMove$);
window.addEventListener('mouseup', handleMouseSnap$);
isMouseMovingSig.value = false;
@@ -129,6 +142,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
context.scrollerRef.value.scrollTo({
left: nonDragSnapPosition,
+ top: nonDragSnapPosition,
behavior: 'smooth',
});
@@ -138,7 +152,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
const updateTouchDeviceIndex$ = $(() => {
if (!context.scrollerRef.value) return;
const container = context.scrollerRef.value;
- const containerScrollLeft = container.scrollLeft;
+ const containerScrollDirection = container[isHorizontal ? 'scrollLeft' : 'scrollTop'];
const slides = context.slideRefsArray.value;
let currentIndex = 0;
@@ -146,8 +160,10 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
slides.forEach((slideRef, index) => {
if (!slideRef.value) return;
- const slideLeft = slideRef.value.offsetLeft;
- const distance = Math.abs(containerScrollLeft - slideLeft);
+ const slideInDirection = isHorizontal
+ ? slideRef.value['offsetLeft']
+ : slideRef.value['offsetTop'] - slideRef.value.parentElement['offsetTop']; // get the offsetTop from the top of the current carousel
+ const distance = Math.abs(containerScrollDirection - slideInDirection);
if (distance < minDistance) {
minDistance = distance;
currentIndex = index;
@@ -165,6 +181,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => {
const newPosition = await getSlidePosition$(context.currentIndexSig.value);
context.scrollerRef.value.scrollTo({
left: newPosition,
+ top: newPosition,
behavior: 'auto',
});
});