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 ( + + + + {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', }); });