Skip to content

Commit f59e183

Browse files
authored
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
1 parent 18f6543 commit f59e183

File tree

8 files changed

+230
-24
lines changed

8 files changed

+230
-24
lines changed

apps/website/src/routes/docs/headless/carousel/examples/carousel.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
.carousel-slide {
1010
border: 2px dotted hsl(var(--primary));
1111
min-height: 10rem;
12-
margin-top: 0.5rem;
12+
-webkit-user-select: none; /* support for Safari */
1313
user-select: none;
1414
}
1515

@@ -24,6 +24,7 @@
2424
display: flex;
2525
justify-content: space-between;
2626
border: 2px dotted hsl(var(--accent));
27+
margin-bottom: 0.5rem;
2728
}
2829

2930
.carousel-buttons button {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { component$, useStyles$ } from '@builder.io/qwik';
2+
import { Carousel } from '@qwik-ui/headless';
3+
4+
export default component$(() => {
5+
useStyles$(styles);
6+
7+
const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink'];
8+
9+
return (
10+
<Carousel.Root class="carousel-root" gap={30} direction="column" maxSlideHeight={160}>
11+
<div class="carousel-buttons">
12+
<Carousel.Previous>Prev</Carousel.Previous>
13+
<Carousel.Next>Next</Carousel.Next>
14+
</div>
15+
<Carousel.Scroller class="carousel-scroller">
16+
{colors.map((color) => (
17+
<Carousel.Slide key={color} class="carousel-slide">
18+
{color}
19+
</Carousel.Slide>
20+
))}
21+
</Carousel.Scroller>
22+
</Carousel.Root>
23+
);
24+
});
25+
26+
// internal
27+
import styles from './carousel.css?inline';

apps/website/src/routes/docs/headless/carousel/index.mdx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS
5050
[data-qui-carousel-scroller] {
5151
overflow: hidden;
5252
display: flex;
53+
flex-direction: var(--direction);
5354
gap: var(--gap);
55+
max-height: var(--max-slide-height);
5456
/* for mobile & scroll-snap-start */
55-
scroll-snap-type: x mandatory;
57+
scroll-snap-type: both mandatory;
5658
}
5759

5860
[data-qui-carousel-slide] {
@@ -67,7 +69,7 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS
6769

6870
@media (pointer: coarse) {
6971
[data-qui-carousel-scroller][data-draggable] {
70-
overflow-x: scroll;
72+
overflow: scroll;
7173
}
7274

7375
/* 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 `<Carousel.Slide />` co
140142

141143
<Showcase name="different-widths" />
142144

145+
### Vertical Direction
146+
147+
Qwik UI supports vertical carousels.
148+
Set the `direction` prop to `column ` and define `maxSlideHeight` prop in px, for making the vertical carousel.
149+
150+
<Showcase name="vertical-direction" />
151+
143152
### No Scroll
144153

145154
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
318327
type: 'number',
319328
description: 'Time in milliseconds before the next slide plays during autoplay.',
320329
},
330+
{
331+
name: 'direction',
332+
type: 'union',
333+
description:
334+
'Change the direction of the carousel, for it to be veritical define the maxSlideHeight prop as well.',
335+
info: '"row" | "column"',
336+
},
337+
{
338+
name: 'maxSlideHeight',
339+
type: 'number',
340+
description: 'Write the height of the longest slide.',
341+
},
321342
]}
322343
/>

packages/kit-headless/src/components/carousel/carousel.css

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
[data-qui-carousel-scroller] {
33
overflow: hidden;
44
display: flex;
5+
flex-direction: var(--direction);
56
gap: var(--gap);
7+
max-height: var(--max-slide-height);
68
/* for mobile & scroll-snap-start */
7-
scroll-snap-type: x mandatory;
9+
scroll-snap-type: both mandatory;
810
}
911

1012
[data-qui-carousel-slide] {
@@ -19,7 +21,7 @@
1921

2022
@media (pointer: coarse) {
2123
[data-qui-carousel-scroller][data-draggable] {
22-
overflow-x: scroll;
24+
overflow: scroll;
2325
}
2426

2527
/* make sure snap align is added after initial index animation */

packages/kit-headless/src/components/carousel/carousel.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,97 @@ test.describe('Mobile / Touch Behavior', () => {
444444
expect(Math.abs(secondSlideBox.x - scrollerBox.x)).toBeLessThan(1); // Allow 1px tolerance
445445
});
446446

447+
test(`GIVEN a mobile vertical carousel
448+
WHEN swiping to the next slide
449+
Then the next slide should snap to the top side of the scroller`, async ({
450+
page,
451+
}) => {
452+
const { driver: d } = await setup(page, 'vertical-direction');
453+
454+
await expect(d.getSlideAt(0)).toHaveAttribute('data-active');
455+
const boundingBox = await d.getSlideBoundingBoxAt(0);
456+
const cdpSession = await page.context().newCDPSession(page);
457+
458+
const startY = boundingBox.y + boundingBox.height * 0.8;
459+
const endY = boundingBox.y;
460+
const x = boundingBox.x + boundingBox.width / 2;
461+
462+
// touch events
463+
await page.touchscreen.tap(x, startY);
464+
465+
await cdpSession.send('Input.dispatchTouchEvent', {
466+
type: 'touchStart',
467+
touchPoints: [{ x, y: startY }],
468+
});
469+
await cdpSession.send('Input.dispatchTouchEvent', {
470+
type: 'touchMove',
471+
touchPoints: [{ x, y: endY }],
472+
});
473+
await cdpSession.send('Input.dispatchTouchEvent', {
474+
type: 'touchEnd',
475+
touchPoints: [{ x, y: startY }],
476+
});
477+
478+
await page.touchscreen.tap(x, endY);
479+
await page.touchscreen.tap(x, startY); // tap the slide to make it visible
480+
await expect(d.getSlideAt(1)).toBeVisible();
481+
482+
await cdpSession.detach();
483+
const scrollerBox = await d.getScrollerBoundingBox();
484+
const secondSlideBox = await d.getSlideBoundingBoxAt(1);
485+
486+
expect(Math.abs(secondSlideBox.y - scrollerBox.y)).toBeLessThan(1); // Allow 1px tolerance
487+
});
488+
489+
test(`GIVEN a mobile vertical carousel
490+
WHEN swiping two times to the next slide and clicking next button
491+
Then the third slide should be visible`, async ({ page }) => {
492+
const { driver: d } = await setup(page, 'vertical-direction');
493+
494+
await expect(d.getSlideAt(0)).toHaveAttribute('data-active');
495+
const boundingBox = await d.getSlideBoundingBoxAt(0);
496+
const cdpSession = await page.context().newCDPSession(page);
497+
498+
const startY = boundingBox.y + boundingBox.height * 0.99;
499+
const endY = boundingBox.y;
500+
const x = boundingBox.x + boundingBox.width / 2;
501+
502+
await cdpSession.send('Input.dispatchTouchEvent', {
503+
type: 'touchStart',
504+
touchPoints: [{ x, y: startY }],
505+
});
506+
await cdpSession.send('Input.dispatchTouchEvent', {
507+
type: 'touchMove',
508+
touchPoints: [{ x, y: endY }],
509+
});
510+
await cdpSession.send('Input.dispatchTouchEvent', {
511+
type: 'touchEnd',
512+
touchPoints: [{ x, y: startY }],
513+
});
514+
await expect(d.getSlideAt(1)).toBeVisible();
515+
516+
await cdpSession.send('Input.dispatchTouchEvent', {
517+
type: 'touchStart',
518+
touchPoints: [{ x, y: startY }],
519+
});
520+
await cdpSession.send('Input.dispatchTouchEvent', {
521+
type: 'touchMove',
522+
touchPoints: [{ x, y: endY }],
523+
});
524+
await cdpSession.send('Input.dispatchTouchEvent', {
525+
type: 'touchEnd',
526+
touchPoints: [{ x, y: startY }],
527+
});
528+
529+
await cdpSession.detach();
530+
531+
await expect(d.getSlideAt(2)).toBeVisible();
532+
533+
await d.getNextButton().tap();
534+
535+
expect(d.getSlideAt(3)).toHaveAttribute('data-active');
536+
});
537+
447538
test(`GIVEN a mobile carousel
448539
WHEN tapping the next button
449540
THEN the next slide should snap to the left side of the scroller`, async ({
@@ -865,6 +956,34 @@ test.describe('State', () => {
865956

866957
await expect(progressBar).toHaveAttribute('aria-valuetext', '17%');
867958
});
959+
960+
test(`GIVEN a carousel with direction column and max slide height declared
961+
WHEN the swipe up or down
962+
THEN the attribute should move to the right slide
963+
`, async ({ page }) => {
964+
const { driver: d } = await setup(page, 'vertical-direction');
965+
d;
966+
967+
const visibleSlide = d.getSlideAt(0);
968+
969+
const slideBox = await visibleSlide.boundingBox();
970+
971+
if (slideBox) {
972+
const startX = slideBox.x + slideBox.width / 2;
973+
const startY = slideBox.y + slideBox.height / 2;
974+
975+
// swipe up from the middle of the visible slide
976+
await page.mouse.move(startX, startY);
977+
await page.mouse.down();
978+
await page.mouse.move(startX, -startY, { steps: 10 });
979+
980+
// finish the swiping and move the mouse back
981+
await page.mouse.up();
982+
await page.mouse.move(startX, startY, { steps: 10 });
983+
}
984+
// checking that the slide changed
985+
expect(d.getSlideAt(0)).not.toHaveAttribute('data-active');
986+
});
868987
});
869988

870989
test.describe('Stepper', () => {

packages/kit-headless/src/components/carousel/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type CarouselContext = {
2424
alignSig: Signal<'start' | 'center' | 'end'>;
2525
isLoopSig: Signal<boolean>;
2626
autoPlayIntervalMsSig: Signal<number>;
27+
directionSig: Signal<'row' | 'column'>;
2728
startIndex: number | undefined;
2829
isStepInteractionSig: Signal<boolean>;
2930
};

packages/kit-headless/src/components/carousel/root.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export type CarouselRootProps = PropsOf<'div'> & {
5656
/** @internal Whether this carousel has a title */
5757
_isTitle?: boolean;
5858

59+
/** The carousel's orientation */
60+
direction?: 'row' | 'column';
61+
62+
/** The slider height */
63+
maxSlideHeight?: number | undefined;
5964
/** Allows the user to navigate steps when interacting with the stepper */
6065
stepInteraction?: boolean;
6166
};
@@ -84,6 +89,7 @@ export const CarouselBase = component$(
8489
startIndex ?? 0,
8590
);
8691
const isScrollerSig = useSignal(false);
92+
const directionSig = useSignal(() => props.direction ?? 'row');
8793
const isAutoplaySig = useBoundSignal(givenAutoplaySig, false);
8894

8995
const getInitialProgress = () => {
@@ -98,6 +104,7 @@ export const CarouselBase = component$(
98104
const alignSig = useComputed$(() => props.align ?? 'start');
99105
const isLoopSig = useComputed$(() => props.loop ?? false);
100106
const autoPlayIntervalMsSig = useComputed$(() => props.autoPlayIntervalMs ?? 0);
107+
const maxSlideHeight = useComputed$(() => props.maxSlideHeight ?? undefined);
101108
const progressSig = useBoundSignal(givenProgressSig, getInitialProgress());
102109
const isStepInteractionSig = useComputed$(() => props.stepInteraction ?? false);
103110

@@ -122,6 +129,7 @@ export const CarouselBase = component$(
122129
alignSig,
123130
isLoopSig,
124131
autoPlayIntervalMsSig,
132+
directionSig,
125133
startIndex,
126134
isStepInteractionSig,
127135
};
@@ -130,6 +138,14 @@ export const CarouselBase = component$(
130138

131139
useContextProvider(carouselContextId, context);
132140

141+
// Max Height needed for making vertical carousel
142+
useTask$(({ track }) => {
143+
track(() => maxSlideHeight.value);
144+
if (!maxSlideHeight.value) {
145+
directionSig.value = 'row';
146+
}
147+
});
148+
133149
useTask$(({ track }) => {
134150
if (!givenProgressSig) return;
135151
track(() => currentIndexSig.value);
@@ -155,6 +171,8 @@ export const CarouselBase = component$(
155171
'--slides-per-view': slidesPerViewSig.value,
156172
'--gap': `${gapSig.value}px`,
157173
'--scroll-snap-align': alignSig.value,
174+
'--direction': directionSig.value,
175+
'--max-slide-height': `${maxSlideHeight.value}px`,
158176
}}
159177
>
160178
<Slot />

0 commit comments

Comments
 (0)