Skip to content

Commit

Permalink
feat: vertical carousel option (#948)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ArkadiK94 authored Sep 15, 2024
1 parent 18f6543 commit f59e183
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -24,6 +24,7 @@
display: flex;
justify-content: space-between;
border: 2px dotted hsl(var(--accent));
margin-bottom: 0.5rem;
}

.carousel-buttons button {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Carousel.Root class="carousel-root" gap={30} direction="column" maxSlideHeight={160}>
<div class="carousel-buttons">
<Carousel.Previous>Prev</Carousel.Previous>
<Carousel.Next>Next</Carousel.Next>
</div>
<Carousel.Scroller class="carousel-scroller">
{colors.map((color) => (
<Carousel.Slide key={color} class="carousel-slide">
{color}
</Carousel.Slide>
))}
</Carousel.Scroller>
</Carousel.Root>
);
});

// internal
import styles from './carousel.css?inline';
25 changes: 23 additions & 2 deletions apps/website/src/routes/docs/headless/carousel/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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 */
Expand Down Expand Up @@ -140,6 +142,13 @@ To change this, use the `flex-basis` CSS property on the `<Carousel.Slide />` co

<Showcase name="different-widths" />

### Vertical Direction

Qwik UI supports vertical carousels.
Set the `direction` prop to `column ` and define `maxSlideHeight` prop in px, for making the vertical carousel.

<Showcase name="vertical-direction" />

### No Scroll

Qwik UI supports carousels without a scroller, which can be useful for conditional slide carousels.
Expand Down Expand Up @@ -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.',
},
]}
/>
6 changes: 4 additions & 2 deletions packages/kit-headless/src/components/carousel/carousel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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 */
Expand Down
119 changes: 119 additions & 0 deletions packages/kit-headless/src/components/carousel/carousel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/kit-headless/src/components/carousel/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type CarouselContext = {
alignSig: Signal<'start' | 'center' | 'end'>;
isLoopSig: Signal<boolean>;
autoPlayIntervalMsSig: Signal<number>;
directionSig: Signal<'row' | 'column'>;
startIndex: number | undefined;
isStepInteractionSig: Signal<boolean>;
};
18 changes: 18 additions & 0 deletions packages/kit-headless/src/components/carousel/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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);

Expand All @@ -122,6 +129,7 @@ export const CarouselBase = component$(
alignSig,
isLoopSig,
autoPlayIntervalMsSig,
directionSig,
startIndex,
isStepInteractionSig,
};
Expand All @@ -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);
Expand All @@ -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`,
}}
>
<Slot />
Expand Down
Loading

0 comments on commit f59e183

Please sign in to comment.