diff --git a/.changeset/wild-tools-brush.md b/.changeset/wild-tools-brush.md new file mode 100644 index 000000000..2ebd7ecb3 --- /dev/null +++ b/.changeset/wild-tools-brush.md @@ -0,0 +1,5 @@ +--- +'@qwik-ui/headless': patch +--- + +feat: carousel now supports mousewheel navigation in vertical mode diff --git a/apps/website/src/routes/docs/headless/carousel/auto-api/api.ts b/apps/website/src/routes/docs/headless/carousel/auto-api/api.ts index 473dbd7e0..f4ce10a1b 100644 --- a/apps/website/src/routes/docs/headless/carousel/auto-api/api.ts +++ b/apps/website/src/routes/docs/headless/carousel/auto-api/api.ts @@ -21,77 +21,98 @@ export const api = { { root: [ { - CarouselRootProps: [ + PublicCarouselRootProps: [ { comment: 'The gap between slides', - prop: 'gap?', + prop: 'gap', type: 'number', }, { comment: 'Number of slides to show at once', - prop: 'slidesPerView?', + prop: 'slidesPerView', type: 'number', }, { comment: 'Whether the carousel is draggable', - prop: 'draggable?', + prop: 'draggable', type: 'boolean', }, { comment: 'Alignment of slides within the viewport', - prop: 'align?', + prop: 'align', type: "'start' | 'center' | 'end'", }, { comment: 'Whether the carousel should rewind', - prop: 'rewind?', + prop: 'rewind', type: 'boolean', }, { comment: 'Bind the selected index to a signal', - prop: "'bind:selectedIndex'?", + prop: "'bind:selectedIndex'", type: 'Signal', }, { comment: 'change the initial index of the carousel on render', - prop: 'startIndex?', + prop: 'startIndex', type: 'number', }, { comment: - '@deprecated Use bind:selectedIndex instead\n Bind the current slide index to a signal', - prop: "'bind:currSlideIndex'?", + '@deprecated Use bind:selectedIndex instead\n Bind the current slide index to a signal', + prop: "'bind:currSlideIndex'", type: 'Signal', }, { comment: 'Whether the carousel should autoplay', - prop: "'bind:autoplay'?", + prop: "'bind:autoplay'", type: 'Signal', }, { comment: 'the current progress of the carousel', - prop: "'bind:progress'?", + prop: "'bind:progress'", type: 'Signal', }, { comment: 'Time in milliseconds before the next slide plays during autoplay', - prop: 'autoPlayIntervalMs?', + prop: 'autoPlayIntervalMs', type: 'number', }, { comment: '@internal Total number of slides', - prop: '_numSlides?', + prop: '_numSlides', type: 'number', }, { comment: '@internal Whether this carousel has a title', - prop: '_isTitle?', + prop: '_isTitle', type: 'boolean', }, { comment: 'The sensitivity of the carousel dragging', - prop: 'sensitivity?', - type: '{', + prop: 'sensitivity', + type: '{\n mouse?: number;\n touch?: number;\n }', + }, + { + comment: + 'The amount of slides to move when hitting the next or previous button', + prop: 'move', + type: 'number', + }, + { + comment: "The carousel's direction", + prop: 'orientation', + type: "'horizontal' | 'vertical'", + }, + { + comment: 'The maximum height of the slides. Needed in vertical carousels', + prop: 'maxSlideHeight', + type: 'number', + }, + { + comment: 'Whether the carousel should support mousewheel navigation', + prop: 'mousewheel', + type: 'boolean', }, ], }, 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 b39762a16..c58e0d4f8 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css +++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css @@ -1,5 +1,6 @@ .carousel-root { width: 100%; + position: relative; } .carousel-slide { diff --git a/apps/website/src/routes/docs/headless/carousel/examples/mousewheel.tsx b/apps/website/src/routes/docs/headless/carousel/examples/mousewheel.tsx new file mode 100644 index 000000000..76687f262 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/mousewheel.tsx @@ -0,0 +1,60 @@ +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']; + + useStyles$(` + .mousewheel-bullet { + width: 10px; + height: 10px; + background: hsl(var(--muted)); + } + + .mousewheel-bullet[data-active] { + background-color: hsl(var(--primary)); + } + + .mousewheel-pagination { + display: flex; + flex-direction: column; + gap: 4px; + position: absolute; + top: 33%; + right: 8px; + } + + `); + + return ( + + + + {colors.map((color) => ( + + {color} + + ))} + + + {colors.map((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 a0449ce6f..6b44ec835 100644 --- a/apps/website/src/routes/docs/headless/carousel/index.mdx +++ b/apps/website/src/routes/docs/headless/carousel/index.mdx @@ -184,6 +184,12 @@ Both SSR and CSR are supported. In this example, we conditionally render the car +### Mousewheel + +The carousel component also supports mousewheel navigation in the case of vertical carousels. + + + ### Rewind Rewind the carousel by setting the `rewind` prop to `true`. diff --git a/packages/kit-headless/src/components/carousel/context.ts b/packages/kit-headless/src/components/carousel/context.ts index dbec90e31..c306c6b21 100644 --- a/packages/kit-headless/src/components/carousel/context.ts +++ b/packages/kit-headless/src/components/carousel/context.ts @@ -10,6 +10,7 @@ export type CarouselContext = { nextButtonRef: Signal; prevButtonRef: Signal; isMouseDraggingSig: Signal; + isMouseWheelSig: Signal; slideRefsArray: Signal>; bulletRefsArray: Signal>; currentIndexSig: Signal; diff --git a/packages/kit-headless/src/components/carousel/root.tsx b/packages/kit-headless/src/components/carousel/root.tsx index 7a9209330..74b813698 100644 --- a/packages/kit-headless/src/components/carousel/root.tsx +++ b/packages/kit-headless/src/components/carousel/root.tsx @@ -70,6 +70,9 @@ export type PublicCarouselRootProps = PropsOf<'div'> & { /** The maximum height of the slides. Needed in vertical carousels */ maxSlideHeight?: number; + + /** Whether the carousel should support mousewheel navigation */ + mousewheel?: boolean; }; export const CarouselBase = component$((props: PublicCarouselRootProps) => { @@ -133,6 +136,7 @@ export const CarouselBase = component$((props: PublicCarouselRootProps) => { } return props.orientation ?? 'horizontal'; }); + const isMouseWheelSig = useComputed$(() => props.mousewheel ?? false); const titleId = `${localId}-title`; @@ -143,6 +147,7 @@ export const CarouselBase = component$((props: PublicCarouselRootProps) => { prevButtonRef, scrollStartRef, isMouseDraggingSig, + isMouseWheelSig, slideRefsArray, bulletRefsArray, currentIndexSig, diff --git a/packages/kit-headless/src/components/carousel/scroller.tsx b/packages/kit-headless/src/components/carousel/scroller.tsx index 92859ea57..923042081 100644 --- a/packages/kit-headless/src/components/carousel/scroller.tsx +++ b/packages/kit-headless/src/components/carousel/scroller.tsx @@ -14,6 +14,7 @@ import styles from './carousel.css?inline'; import { isServer } from '@builder.io/qwik/build'; import { useDebouncer } from '../../hooks/use-debouncer'; import { useScroller } from './use-scroller'; +import { useCarousel } from './use-carousel'; export const CarouselScroller = component$((props: PropsOf<'div'>) => { useStyles$(styles); @@ -27,6 +28,8 @@ export const CarouselScroller = component$((props: PropsOf<'div'>) => { const initialLoadSig = useSignal(true); const isNewPosOnLoadSig = useSignal(false); + const { validIndexesSig } = useCarousel(context); + const { startPosSig, transformSig, @@ -210,6 +213,23 @@ export const CarouselScroller = component$((props: PropsOf<'div'>) => { context.currentIndexSig.value !== 0; }); + const handleWheel = $(async (e: WheelEvent) => { + if (!context.isDraggableSig.value || !context.scrollerRef.value) return; + if (!context.isMouseWheelSig.value) return; + + const validIndexes = validIndexesSig.value; + const currentIndex = context.currentIndexSig.value; + const currentPosition = validIndexes.indexOf(currentIndex); + const direction = e.deltaY > 0 ? 1 : -1; + + // check if in bounds + const newPosition = Math.max( + 0, + Math.min(currentPosition + direction, validIndexes.length - 1), + ); + context.currentIndexSig.value = validIndexes[newPosition]; + }); + useTask$(() => { initialLoadSig.value = false; }); @@ -224,6 +244,8 @@ export const CarouselScroller = component$((props: PropsOf<'div'>) => { preventdefault:touchstart preventdefault:touchmove onQVisible$={isNewPosOnLoadSig.value ? setInitialSlidePos : undefined} + onWheel$={handleWheel} + preventdefault:wheel >