diff --git a/.changeset/healthy-pumpkins-repair.md b/.changeset/healthy-pumpkins-repair.md new file mode 100644 index 000000000..6e87e6431 --- /dev/null +++ b/.changeset/healthy-pumpkins-repair.md @@ -0,0 +1,11 @@ +--- +'@qwik-ui/headless': patch +--- + +## Carousel + +The carousel has been refactored from the ground up, including new features, components, and QOL updates. It is still in a draft state, and development is ongoing. + +## Dropdown + +More improvements have been made to the dropdown component, including new features, components, and QOL updates. 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 b280c8e79..92a4a26fe 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css +++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css @@ -1,30 +1,16 @@ -.carousel { - --slide-size: 100%; - --slide-height: 5rem; - height: 100%; - max-width: 500px; - overflow: hidden; +.carousel-root { + width: 100%; } -.carousel-container { - backface-visibility: hidden; - display: flex; - touch-action: pan-y; - margin-left: calc(var(--slide-spacing) * -1); - transition-property: transform; - transition-timing-function: ease; - overflow-x: visible; - padding-block: 0.5rem; +.carousel-scroller { + margin-bottom: 0.5rem; } .carousel-slide { - flex: 0 0 var(--slide-size); - min-width: 0; - position: relative; - user-select: none; - transition-property: transform; border: 2px dotted hsl(var(--primary)); - pointer-events: none; + min-height: 10rem; + margin-top: 0.5rem; + user-select: none; } .carousel-pagination { @@ -32,6 +18,7 @@ gap: 0.5rem; padding: 1rem; border: 2px dotted hsl(var(--foreground)); + outline: none; } .carousel-buttons { @@ -40,10 +27,6 @@ border: 2px dotted hsl(var(--accent)); } -.carousel-buttons button[aria-disabled='true'] { - opacity: 0.5; -} - .carousel-buttons button { border: 2px dotted hsl(var(--foreground)); padding: 0.5rem; @@ -53,8 +36,8 @@ background-color: hsla(var(--primary) / 0.08); } -.carousel-img { - pointer-events: none; +.carousel-buttons button:disabled { + opacity: 0.5; } .carousel-pagination-bullet { @@ -62,7 +45,7 @@ padding-inline: 0.5rem; } -.pagination-underline { +.carousel-pagination-bullet[data-active] { outline: 2px dotted hsl(var(--primary)); background-color: hsla(var(--primary) / 0.08); } @@ -70,3 +53,21 @@ .carousel-pagination-bullet:hover { background-color: hsla(var(--primary) / 0.08); } + +.carousel-conditional { + position: relative; + height: 200px; +} + +.carousel-conditional .carousel-slide { + opacity: 0; + transition: opacity 0.5s; + /* NOT display block */ + display: revert; + position: absolute; + inset: 0; +} + +.carousel-conditional .carousel-slide[data-active] { + opacity: 1; +} diff --git a/apps/website/src/routes/docs/headless/carousel/examples/center.tsx b/apps/website/src/routes/docs/headless/carousel/examples/center.tsx new file mode 100644 index 000000000..21e81a724 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/center.tsx @@ -0,0 +1,30 @@ +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/examples/conditional.tsx b/apps/website/src/routes/docs/headless/carousel/examples/conditional.tsx new file mode 100644 index 000000000..fbebf2938 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/conditional.tsx @@ -0,0 +1,26 @@ +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 ( + + + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/csr.tsx b/apps/website/src/routes/docs/headless/carousel/examples/csr.tsx new file mode 100644 index 000000000..3ffe8b77a --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/csr.tsx @@ -0,0 +1,34 @@ +import { component$, useSignal, 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']; + const renderCarousel = useSignal(false); + + return ( + <> + + {renderCarousel.value && ( + + + + {colors.map((color) => ( + + {color} + + ))} + + + )} + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/different-widths.tsx b/apps/website/src/routes/docs/headless/carousel/examples/different-widths.tsx new file mode 100644 index 000000000..ca0b049b0 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/different-widths.tsx @@ -0,0 +1,34 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + + + red + + + green + + + blue + + + yellow + + + purple + + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/end.tsx b/apps/website/src/routes/docs/headless/carousel/examples/end.tsx new file mode 100644 index 000000000..253486891 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/end.tsx @@ -0,0 +1,30 @@ +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/examples/hero.tsx b/apps/website/src/routes/docs/headless/carousel/examples/hero.tsx index 0eaa8a7bf..aca9f3d26 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/hero.tsx +++ b/apps/website/src/routes/docs/headless/carousel/examples/hero.tsx @@ -1,139 +1,26 @@ -import { $, component$, useSignal, useStyles$ } from '@builder.io/qwik'; - +import { component$, useStyles$ } from '@builder.io/qwik'; import { Carousel } from '@qwik-ui/headless'; -import carouselStyles from './carousel.css?inline'; export default component$(() => { - /* TODO: document this to always have initial state to null. - Use defaultSlide instead for setting a slide on page load */ - const currentIndexSig = useSignal(0); - useStyles$(carouselStyles); + useStyles$(styles); - const slideImageMetadata = [ - { - id: '10', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/6J--NXulQCs', - download_url: 'https://picsum.photos/id/10/2500/1667', - }, - { - id: '11', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/Cm7oKel-X2Q', - download_url: 'https://picsum.photos/id/11/2500/1667', - }, - { - id: '12', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/I_9ILwtsl_k', - download_url: 'https://picsum.photos/id/12/2500/1667', - }, - { - id: '13', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/3MtiSMdnoCo', - download_url: 'https://picsum.photos/id/13/2500/1667', - }, - { - id: '14', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/IQ1kOQTJrOQ', - download_url: 'https://picsum.photos/id/14/2500/1667', - }, - { - id: '15', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/NYDo21ssGao', - download_url: 'https://picsum.photos/id/15/2500/1667', - }, - { - id: '16', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/gkT4FfgHO5o', - download_url: 'https://picsum.photos/id/16/2500/1667', - }, - { - id: '17', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/Ven2CV8IJ5A', - download_url: 'https://picsum.photos/id/17/2500/1667', - }, - { - id: '18', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/Ps2n0rShqaM', - download_url: 'https://picsum.photos/id/18/2500/1667', - }, - { - id: '19', - author: 'Paul Jarvis', - width: 2500, - height: 1667, - url: 'https://unsplash.com/photos/P7Lh0usGcuk', - download_url: 'https://picsum.photos/id/19/2500/1667', - }, - ]; + const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; return ( - + - - - {slideImageMetadata.map((data) => ( - - {data.author} - - ))} - - -
- { - return ( -
(currentIndexSig.value = i)} - > - {i} -
- ); - })} - /> + Prev + Next
+ + {colors.map((color) => ( + + {color} + + ))} +
); }); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/images.tsx b/apps/website/src/routes/docs/headless/carousel/examples/images.tsx new file mode 100644 index 000000000..3e6cec808 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/images.tsx @@ -0,0 +1,114 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + + {slideImageMetadata.map((data) => ( + + {data.author} + + ))} + + + ); +}); + +const slideImageMetadata = [ + { + id: '10', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/6J--NXulQCs', + download_url: 'https://picsum.photos/id/10/2500/1667', + }, + { + id: '11', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/Cm7oKel-X2Q', + download_url: 'https://picsum.photos/id/11/2500/1667', + }, + { + id: '12', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/I_9ILwtsl_k', + download_url: 'https://picsum.photos/id/12/2500/1667', + }, + { + id: '13', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/3MtiSMdnoCo', + download_url: 'https://picsum.photos/id/13/2500/1667', + }, + { + id: '14', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/IQ1kOQTJrOQ', + download_url: 'https://picsum.photos/id/14/2500/1667', + }, + { + id: '15', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/NYDo21ssGao', + download_url: 'https://picsum.photos/id/15/2500/1667', + }, + { + id: '16', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/gkT4FfgHO5o', + download_url: 'https://picsum.photos/id/16/2500/1667', + }, + { + id: '17', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/Ven2CV8IJ5A', + download_url: 'https://picsum.photos/id/17/2500/1667', + }, + { + id: '18', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/Ps2n0rShqaM', + download_url: 'https://picsum.photos/id/18/2500/1667', + }, + { + id: '19', + author: 'Paul Jarvis', + width: 2500, + height: 1667, + url: 'https://unsplash.com/photos/P7Lh0usGcuk', + download_url: 'https://picsum.photos/id/19/2500/1667', + }, +]; + +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/initial.tsx b/apps/website/src/routes/docs/headless/carousel/examples/initial.tsx new file mode 100644 index 000000000..1385cd6e8 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/initial.tsx @@ -0,0 +1,26 @@ +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/examples/loop.tsx b/apps/website/src/routes/docs/headless/carousel/examples/loop.tsx new file mode 100644 index 000000000..23b4588ca --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/loop.tsx @@ -0,0 +1,33 @@ +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} + + ))} + + + {colors.map((color, index) => ( + + {index + 1} + + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/multiple-slides.tsx b/apps/website/src/routes/docs/headless/carousel/examples/multiple-slides.tsx new file mode 100644 index 000000000..4494e1223 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/multiple-slides.tsx @@ -0,0 +1,33 @@ +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} + + ))} + + + {colors.map((color, index) => ( + + {index + 1} + + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/non-draggable.tsx b/apps/website/src/routes/docs/headless/carousel/examples/non-draggable.tsx new file mode 100644 index 000000000..353aae190 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/non-draggable.tsx @@ -0,0 +1,26 @@ +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/examples/pagination.tsx b/apps/website/src/routes/docs/headless/carousel/examples/pagination.tsx new file mode 100644 index 000000000..1ebc0e535 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/pagination.tsx @@ -0,0 +1,33 @@ +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} + + ))} + + + {colors.map((color, index) => ( + + {index + 1} + + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/player.tsx b/apps/website/src/routes/docs/headless/carousel/examples/player.tsx new file mode 100644 index 000000000..7ffe2a098 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/player.tsx @@ -0,0 +1,41 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; +import { LuPause, LuPlay } from '@qwikest/icons/lucide'; + +export default component$(() => { + useStyles$(styles); + + const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; + const isPlaying = useSignal(false); + + return ( + <> + + + + {colors.map((color, index) => ( + + {color} +
{index === 1 && }
+
+ ))} +
+
+

isPlaying: {isPlaying.value.toString()}

+ + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/reactive.tsx b/apps/website/src/routes/docs/headless/carousel/examples/reactive.tsx new file mode 100644 index 000000000..b05735d6b --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/reactive.tsx @@ -0,0 +1,31 @@ +import { component$, useSignal, 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']; + const selectedIndex = useSignal(0); + + return ( + <> + + + + {colors.map((color) => ( + + {color} + + ))} + + + + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/title.tsx b/apps/website/src/routes/docs/headless/carousel/examples/title.tsx new file mode 100644 index 000000000..ffdfa752e --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/title.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 ( + + Favorite Colors + + + {colors.map((color) => ( + + {color} + + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/without-scroller.tsx b/apps/website/src/routes/docs/headless/carousel/examples/without-scroller.tsx new file mode 100644 index 000000000..28750b838 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/without-scroller.tsx @@ -0,0 +1,24 @@ +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 8ecda9571..d57efd14a 100644 --- a/apps/website/src/routes/docs/headless/carousel/index.mdx +++ b/apps/website/src/routes/docs/headless/carousel/index.mdx @@ -1,12 +1,14 @@ import { statusByComponent } from '~/_state/component-statuses'; + import { FeatureList } from '~/components/feature-list/feature-list'; + import { Note } from '~/components/note/note'; # Carousel -A control that displays a set of content in a scrolling container. +Displays multiple content items in one space, rotating through them. @@ -14,32 +16,260 @@ A control that displays a set of content in a scrolling container. -> Want to speed up the process of getting this component ready for production? Check out the [contributing guide](/docs/headless/contributing)! +## CSS Scroll snapping + +Qwik UI combines CSS scroll snapping and flexbox for the carousel: + +- Scroll snapping: Used on mobile for smooth touch interactions and initial snap position. +- Flexbox: Provides a simple layout system for variable widths, gaps, and columns. + +> Styles are in an @layer for easy customization: + +```css +@layer qwik-ui { + [data-qui-carousel-scroller] { + overflow: hidden; + display: flex; + gap: var(--gap); + /* for mobile & scroll-snap-start */ + scroll-snap-type: x mandatory; + } + + [data-qui-carousel-slide] { + /* default, feel free to override */ + --total-gap-width: calc(var(--gap) * (var(--slides-per-view) - 1)); + --available-slide-width: calc(100% - var(--total-gap-width)); + --slide-width: calc(var(--available-slide-width) / var(--slides-per-view)); + + flex-basis: var(--slide-width); + flex-shrink: 0; + } + + @media (pointer: coarse) { + [data-qui-carousel-scroller][data-draggable] { + overflow-x: scroll; + } + + /* make sure snap align is added after initial index animation */ + [data-draggable][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: start; + } + + [data-draggable][data-align='center'][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: center; + } + + [data-draggable][data-align='end'][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: end; + } + } +} +``` + +## Pagination + +Use `` and `` components to add pagination. + + + +> These are exposed to assistive technologies as tabs for screen readers. + +## Multiple Slides + +Set the `slidesPerView` prop for multiple slides. + + + +## Non-draggable + +Opt-out of the draggable behavior by setting the `draggable` prop to `false`. + + + +## Different widths + +By default, the slides will take up the full width of the carousel. + +To change this, use the `flex-basis` CSS property on the `` component. + + + +## Without Scroller + +Qwik UI supports carousels without a scroller, which can be useful for conditional slide carousels. + + + +Remove the `` component to remove the scroller. + +## Animations + +### Conditional Slides + + -### What's different to Swiper JS or Embla Carousel? +```css +.carousel-conditional { + position: relative; + height: 200px; +} -This component initially started as a wrapper around Swiper JS, but has since been rewritten natively in Qwik! It's a automatically optimized and a lightweight solution. +.carousel-conditional .carousel-slide { + opacity: 0; + transition: opacity 0.5s; + /* NOT display block */ + display: revert; + position: absolute; + inset: 0; +} -This component also intends to be more accessible with a powerful API. +.carousel-conditional .carousel-slide[data-active] { + opacity: 1; +} +``` -### Why is there styles currently directly in headless? +## CSR -The component itself does include headless logic, but also uses the flex layout algorithm, and is in sort of a "gray" zone betweeen styled / unstyled. +Both SSR and CSR are supported. In this example, we conditionally render the carousel based on an interaction. -In the future, we would prefer a polished headless component (devoid of styles for most part). + + +## Center + +Align slides to the center of the carousel by setting the `align` prop to `center`. + + + +## End + +Align slides to the end of the carousel by setting the `align` prop to `end`. + + + +## Loop + +Loop the carousel by setting the `loop` prop to `true`. + + + +> When looping, navigation buttons are never disabled. + +## Accessible Name + +Add an accessible name to the carousel by adding the `` component. + + + +To hide the title from screen readers, use the `` component. + +> The title is automatically added to the carousel's `aria-labelledby` attribute. + +## Autoplay + +To use autoplay, use the `bind:autoplay` prop. + + + +### What if I want to autoplay on initial render? + +Use a visible task to change the signal passed to `bind:autoplay` to `true` when the component is visible. + +```tsx + {/* inside your component */} + useVisibleTask$(() => { + isAutoplaySig.value = true; + }) + + {/* the carousel */} + +``` + +## Initial + +To set an initial slide position, use the `startIndex` prop. + + + +## Reactive + +Reactively control the selected slide index by using the `bind:selectedIndex` prop. + + + +## API + +### Carousel.Root + +', + description: 'Bind the selected index to a signal.', + }, + { + name: 'startIndex', + type: 'number', + description: 'Change the initial index of the carousel on render.', + }, + { + name: 'bind:autoplay', + type: 'Signal', + description: 'Whether the carousel should autoplay.', + }, + { + name: 'autoPlayIntervalMs', + type: 'number', + description: 'Time in milliseconds before the next slide plays during autoplay.', + }, + ]} +/> +import {info} from 'console'; diff --git a/cla-signs/v1/cla.json b/cla-signs/v1/cla.json index 113fdc368..b6d41b196 100644 --- a/cla-signs/v1/cla.json +++ b/cla-signs/v1/cla.json @@ -545,4 +545,4 @@ "pullRequestNo": 904 } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 946fa2e3c..20a46985e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build.headless": "nx build headless", "change": "changeset", "changeset.version": "changeset version && pnpm install --no-frozen-lockfile && git add --all", - "dev": "nx serve website", + "dev": "nx serve website --host", "dev.ct": "nx serve component-tests", "format.all": "prettier --write \"**/*.{js,jsx,ts,tsx,json,html,css,scss}\"", "format.fix": "pretty-quick --staged", @@ -41,8 +41,8 @@ "packageManager": "pnpm@9.6.0", "devDependencies": { "@axe-core/playwright": "^4.9.1", - "@builder.io/qwik": "1.7.0", - "@builder.io/qwik-city": "1.7.0", + "@builder.io/qwik": "^1.7.2", + "@builder.io/qwik-city": "^1.7.2", "@changesets/cli": "^2.27.3", "@changesets/get-github-info": "^0.6.0", "@changesets/types": "^6.0.0", @@ -102,7 +102,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-cypress": "^3.2.0", "eslint-plugin-playwright": "^1.6.2", - "eslint-plugin-qwik": "^1.7.0", + "eslint-plugin-qwik": "^1.7.2", "focus-trap": "7.5.4", "husky": "^9.0.11", "jest": "^29.7.0", diff --git a/packages/kit-headless/package.json b/packages/kit-headless/package.json index 0f75dddc7..a4c04ffd6 100644 --- a/packages/kit-headless/package.json +++ b/packages/kit-headless/package.json @@ -30,7 +30,7 @@ }, "private": false, "peerDependencies": { - "@builder.io/qwik": "1.7.0" + "@builder.io/qwik": "1.7.2" }, "dependencies": { "@floating-ui/core": "^1.6.2", diff --git a/packages/kit-headless/src/components/carousel/actionable.md b/packages/kit-headless/src/components/carousel/actionable.md new file mode 100644 index 000000000..91a991e0b --- /dev/null +++ b/packages/kit-headless/src/components/carousel/actionable.md @@ -0,0 +1,21 @@ +# Accessibility Improvements for Carousel Component + +## 1. Implement auto-rotation control + +- Add a start/stop button for users to control auto-rotation [x] + +LET CONSUMERS DO: + +- Pause rotation on hover, focus +- Disable auto-rotation completely as an option + +## 5. Ensure proper color contrast + +- Maintain sufficient contrast for controls and text, especially when overlaying images +- Consider moving controls and captions outside the image area + +## 6. Implement focus styling + +- Highlight the entire tab list when a tab receives focus +- Ensure focus indicators are visible in high contrast mode +- Place the rotation control as the first element in the Tab sequence inside the carousel diff --git a/packages/kit-headless/src/components/carousel/bullet.tsx b/packages/kit-headless/src/components/carousel/bullet.tsx new file mode 100644 index 000000000..c638d0099 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/bullet.tsx @@ -0,0 +1,107 @@ +import { + PropsOf, + Slot, + component$, + $, + useContext, + useTask$, + useSignal, + sync$, +} from '@builder.io/qwik'; +import { carouselContextId } from './context'; + +type BulletProps = PropsOf<'button'> & { + _index?: number; +}; + +export const CarouselBullet = component$(({ _index, ...props }: BulletProps) => { + const context = useContext(carouselContextId); + const bulletRef = useSignal(); + const slideId = `${context.localId}-${_index ?? -1}`; + const isRenderedSig = useSignal(true); + + useTask$(function getIndexOrder() { + if (_index !== undefined) { + context.bulletRefsArray.value[_index] = bulletRef; + } else { + throw new Error('Qwik UI: Carousel Bullet cannot find its proper index.'); + } + }); + + const handleClick$ = $(() => { + if (typeof _index !== 'number') return; + context.currentIndexSig.value = _index; + }); + + const handleFocus$ = $(() => { + if (typeof _index !== 'number') return; + context.currentIndexSig.value = _index; + }); + + const handleKeyDownSync$ = sync$((e: KeyboardEvent) => { + if (e.key === 'Home' || e.key === 'End') { + e.preventDefault(); + } + }); + + const handleKeyDown$ = $((e: KeyboardEvent) => { + const usedKeys = ['ArrowRight', 'ArrowLeft', 'Home', 'End']; + + if (typeof _index !== 'number' || !usedKeys.includes(e.key)) return; + + if (e.key === 'Home') { + context.currentIndexSig.value = 0; + context.bulletRefsArray.value[0].value.focus(); + return; + } + + if (e.key === 'End') { + const lastIndex = context.numSlidesSig.value - 1; + context.currentIndexSig.value = lastIndex; + context.bulletRefsArray.value[lastIndex].value.focus(); + return; + } + + const totalBullets = context.bulletRefsArray.value.length; + const direction = e.key === 'ArrowRight' ? 1 : -1; + let newIndex = _index + direction; + + if (context.isLoopSig.value) { + newIndex = (newIndex + totalBullets) % totalBullets; + } else { + newIndex = Math.max(0, Math.min(newIndex, totalBullets - 1)); + } + + context.bulletRefsArray.value[newIndex].value.focus(); + }); + + useTask$(function renderAvailableBullets() { + const lastScrollableIndex = + context.numSlidesSig.value - context.slidesPerViewSig.value; + + if (typeof _index !== 'number') return; + + if (_index > lastScrollableIndex) { + isRenderedSig.value = false; + } + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/components/carousel/carousel-container.tsx b/packages/kit-headless/src/components/carousel/carousel-container.tsx deleted file mode 100644 index c2ac651d3..000000000 --- a/packages/kit-headless/src/components/carousel/carousel-container.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { component$, type PropsOf, Slot, useContext } from '@builder.io/qwik'; -import CarouselContextId from './carousel-context-id'; - -type CarouselContainerProps = PropsOf<'div'>; - -export const HCarouselContainer = component$((props: CarouselContainerProps) => { - const context = useContext(CarouselContextId); - - return ( -
- -
- ); -}); diff --git a/packages/kit-headless/src/components/carousel/carousel-context-id.ts b/packages/kit-headless/src/components/carousel/carousel-context-id.ts deleted file mode 100644 index b63a3db52..000000000 --- a/packages/kit-headless/src/components/carousel/carousel-context-id.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createContextId } from '@builder.io/qwik'; -import { type CarouselContext } from './carousel-context.type'; - -const CarouselContextId = createContextId('carousel-context'); - -export default CarouselContextId; diff --git a/packages/kit-headless/src/components/carousel/carousel-context.type.ts b/packages/kit-headless/src/components/carousel/carousel-context.type.ts deleted file mode 100644 index 49982f158..000000000 --- a/packages/kit-headless/src/components/carousel/carousel-context.type.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { type Signal } from '@builder.io/qwik'; - -export interface CarouselContext { - // source of truth - slideOffsetSig: Signal; - numSlidesSig: Signal; - spaceBetweenSlides: number; - slideRefsArray: Signal>; - - /* - refs - (I don't like adding sig to refs - because we know they are signals in qwik) - */ - viewportRef: Signal; - containerRef: Signal; - - // animation - transitionDurationSig: Signal; - - // signal binds - currentIndexSig: Signal; - - // dragging - isDraggingSig: Signal; - initialX: Signal; - initialTransformX: Signal; -} diff --git a/packages/kit-headless/src/components/carousel/carousel-viewport.tsx b/packages/kit-headless/src/components/carousel/carousel-viewport.tsx deleted file mode 100644 index 3925c7e67..000000000 --- a/packages/kit-headless/src/components/carousel/carousel-viewport.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { - component$, - type PropsOf, - Slot, - useContext, - $, - useSignal, - useTask$, -} from '@builder.io/qwik'; -import CarouselContextId from './carousel-context-id'; -import { isBrowser } from '@builder.io/qwik/build'; - -type CarouselViewportProps = PropsOf<'div'>; - -export const HCarouselView = component$((props: CarouselViewportProps) => { - const context = useContext(CarouselContextId); - - const totalWidthSig = useSignal(0); - - useTask$(({ track }) => { - track(() => context.isDraggingSig.value); - - if (isBrowser) { - totalWidthSig.value = - context.numSlidesSig.value * - context.slideRefsArray.value[0].value.offsetWidth * - -1; - } - }); - - const handlePointerMove$ = $((e: PointerEvent) => { - if (context.isDraggingSig.value) { - if (!context.containerRef.value) { - return; - } - - context.transitionDurationSig.value = 0; - - const deltaX = e.clientX - context.initialX.value; - - // const containerWidth = context.containerRef.value.scrollWidth; - const containerLeftOffset = context.initialTransformX.value + deltaX; - - if (containerLeftOffset > 50) { - return; - } - - // TODO: fix bug where pointer down before the animation ends (slow move) - - context.slideOffsetSig.value = containerLeftOffset; - - /* --- */ - - /* TODO: Optimize this by checking prev and next of current slides first */ - for (let i = 0; i < context.slideRefsArray.value.length; i++) { - const slideRef = context.slideRefsArray.value[i]; - - if (!context.containerRef.value || !slideRef.value) { - return; - } - - /* - TODO: figure out why a separate DOMMatrix is why dragging and the buttons work properly. - */ - const style = window.getComputedStyle(context.containerRef.value); - const matrix = new DOMMatrix(style.transform); - - const containerTranslateX = matrix.m41; - // How far to the left the slides container is shifted. - const absContainerTranslateX = Math.abs(containerTranslateX); - - if (!context.viewportRef.value) { - return; - } - - // How far the left edge of this slide is from the left of the slides container. - const slideSlideContainerLeftOffset = slideRef.value.offsetLeft; - // How far the right edge of this slide is from the left of the slides container - // (includes space between slide). - const slideRightEdgePos = - slideSlideContainerLeftOffset + - slideRef.value.offsetWidth + - context.spaceBetweenSlides; - - const carouselViewportWidth = context.viewportRef.value.offsetWidth; - const halfViewportWidth = carouselViewportWidth / 2; - - const isWithinBounds = - absContainerTranslateX > slideSlideContainerLeftOffset - halfViewportWidth && - absContainerTranslateX < slideRightEdgePos - halfViewportWidth; - - if (isWithinBounds) { - context.currentIndexSig.value = i || 0; - break; - } - } - } - }); - - return ( -
{ - // Do nothing if this is not the primary button (e.g., right click). - if (e.pointerType === 'mouse' && e.button !== 0) { - return; - } - - context.initialX.value = e.clientX; - if (context.containerRef.value) { - const style = window.getComputedStyle(context.containerRef.value); - const matrix = new DOMMatrix(style.transform); - context.initialTransformX.value = matrix.m41; - - context.isDraggingSig.value = true; - } - - window.addEventListener('pointermove', handlePointerMove$); - }} - /* removes pointer move event from pointer up created in slide.tsx */ - window:onPointerUp$={() => - window.removeEventListener('pointermove', handlePointerMove$) - } - ref={context.viewportRef} - style={{ overflowX: 'visible', position: 'relative' }} - onTransitionEnd$={() => (context.transitionDurationSig.value = 0)} - {...props} - > - -
- ); -}); diff --git a/packages/kit-headless/src/components/carousel/carousel.css b/packages/kit-headless/src/components/carousel/carousel.css new file mode 100644 index 000000000..91aa581ef --- /dev/null +++ b/packages/kit-headless/src/components/carousel/carousel.css @@ -0,0 +1,80 @@ +@layer qwik-ui { + [data-qui-carousel-scroller] { + overflow: hidden; + display: flex; + gap: var(--gap); + /* for mobile & scroll-snap-start */ + scroll-snap-type: x mandatory; + } + + [data-qui-carousel-slide] { + /* default, feel free to override */ + --total-gap-width: calc(var(--gap) * (var(--slides-per-view) - 1)); + --available-slide-width: calc(100% - var(--total-gap-width)); + --slide-width: calc(var(--available-slide-width) / var(--slides-per-view)); + + flex-basis: var(--slide-width); + flex-shrink: 0; + } + + @media (pointer: coarse) { + [data-qui-carousel-scroller][data-draggable] { + overflow-x: scroll; + } + + /* make sure snap align is added after initial index animation */ + [data-draggable][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: start; + } + + [data-draggable][data-align='center'][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: center; + } + + [data-draggable][data-align='end'][data-initial-touch] [data-qui-carousel-slide] { + scroll-snap-align: end; + } + } + + @media (prefers-reduced-motion: reduce) { + [data-qui-carousel-player] { + display: none; + } + } + + /* workaround until scroll-snap-start is added to CSS */ + [data-qui-scroll-start] { + clip-path: inset(50%); + height: 1px; + width: 1px; + white-space: nowrap; + visibility: hidden; + } + + /* so the snap start marker is not included in the scroll snap position */ + [data-qui-scroll-start][data-start] { + margin-right: calc(-1 * var(--gap) - 1px); + } + + [data-qui-scroll-start][data-center] { + margin-left: 0px; + } + + [data-qui-scroll-start][data-end] { + margin-left: calc(-1 * var(--gap) - 1px); + } + + [data-qui-scroll-start]::before { + content: ''; + height: 1px; + width: 1px; + display: block; + /* changes to none on first interaction */ + scroll-snap-align: var(--scroll-snap-align, start); + } + + /* remove the marker's snap-align on hover */ + [data-qui-carousel-scroller]:hover [data-qui-scroll-start]::before { + scroll-snap-align: unset; + } +} diff --git a/packages/kit-headless/src/components/carousel/carousel.test.ts b/packages/kit-headless/src/components/carousel/carousel.test.ts new file mode 100644 index 000000000..a8434d916 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/carousel.test.ts @@ -0,0 +1,516 @@ +import { test, type Page, expect } from '@playwright/test'; +import { createTestDriver } from './driver'; +import { AxeBuilder } from '@axe-core/playwright'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`headless/carousel/${exampleName}`); + + const driver = createTestDriver(page); + + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + test(`GIVEN a carousel + WHEN clicking on the next button + THEN it should move to the next slide + `, async ({ page }) => { + /* + example that gets used goes here. In this case it's hero from: + apps/website/src/routes/docs/headless/carousel/examples/hero.tsx + + If you type in 'test' in the setup parameter it will look for the test.tsx file + */ + const { driver: d } = await setup(page, 'hero'); + + await d.getNextButton().click(); + + // every slide might be "visible" in the case of scroller carousels. It might be easier to check if the slide has the data-active attribute. + await expect(d.getSlideAt(1)).toBeVisible(); + }); + + test(`GIVEN a carousel + WHEN clicking on the previous button + THEN it should move to the previous slide + `, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // initial setup (if this gets used often we can make it a function in dthe drriver) + await d.getNextButton().click(); + await expect(d.getSlideAt(1)).toBeVisible(); + + // test previous work + }); + + test(`GIVEN a carousel with dragging enabled + WHEN using a pointer device and dragging to the left + THEN it should move to the next slide + `, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN using a pointer device and dragging to the right + THEN it should move to the previous slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN clicking on the pagination bullets + THEN it should move to the corresponding slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN on the first slide and the mouse is moved far right + THEN it should stay snapped on the first slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN on the last slide and the mouse is moved far left + THEN it should stay snapped on the last slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); +}); + +test.describe('Keyboard Behavior', () => { + test(`GIVEN a carousel + WHEN the enter key is pressed on the focused next button + THEN it should move to the next slide + `, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + await d.getNextButton().press('Enter'); + + await expect(d.getSlideAt(1)).toBeVisible(); + }); + + test(`GIVEN a carousel + WHEN the enter key is pressed on the focused previous button + THEN it should move to the previous slide + `, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the first bullet is focused and the right arrow key is pressed + THEN focus should move to the next bullet +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the 2nd bullet is focused and the left arrow key is pressed + THEN focus should move to the previous bullet +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the first bullet is focused and the right arrow key is pressed + THEN focus should move to the 2nd slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the 2nd bullet is focused and the left arrow key is pressed + THEN it should move to the 1st slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the 1st bullet is focused and the end key is pressed + THEN it should move to the last slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN the last bullet is focused and the home key is pressed + THEN it should move to the first slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); +}); + +test.describe('Mobile / Touch Behavior', () => { + test(`GIVEN a carousel with dragging enabled + WHEN swiping to the left + THEN it should move to the next slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN swiping to the right + THEN it should move to the previous slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN tapping on the pagination bullets + THEN it should move to the corresponding slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN on the first slide and is mobile swiped far left + THEN it should stay snapped on the last slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with dragging enabled + WHEN on the last slide and is mobile swiped far right + THEN it should stay snapped on the first slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); +}); + +test.describe('Accessibility', () => { + test('Axe Validation Test', async ({ page }) => { + await setup(page, 'hero'); + + const initialResults = await new AxeBuilder({ page }) + .include('[data-qui-carousel]') + .analyze(); + + expect(initialResults.violations).toEqual([]); + }); + + test(`GIVEN a carousel + WHEN it is rendered + THEN it should have an accessible name +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + await expect(d.getRoot()).toBeVisible(); + await expect(d.getRoot()).toHaveAttribute('aria-label', 'content slideshow'); + }); + + test(`GIVEN a carousel with a title + WHEN it is rendered + THEN the carousel container should have the role of group + AND the title should be the accessible name`, async ({ page }) => { + const { driver: d } = await setup(page, 'title'); + + await expect(d.getRoot()).toBeVisible(); + await expect(d.getRoot()).toHaveAttribute('aria-labelledby', 'Favorite Colors'); + }); + + test(`GIVEN a carousel + WHEN it is rendered + THEN the carousel container should have the role of group +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel + WHEN it is rendered + THEN the items should have a posinset of its current index +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN it is rendered + THEN the parent of the slide tabs should have the role of tablist +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a pagination control + WHEN it is rendered + THEN each bullet should have the role of tab +`, async ({ page }) => { + const { driver: d } = await setup(page, 'pagination'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + // it should also have aria-live polite and announce the current slide + + test(`GIVEN a carousel + WHEN a slide is not the current slide + THEN it should be inert +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel + WHEN on the current slide + THEN items inside the slide should be the only focusable items +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with loop disabled + WHEN on the last slide + THEN the previous button should be focused +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with loop disabled + WHEN on the first slide + THEN the next button should be focused +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); +}); + +test.describe('Props', () => { + test.describe('loop', () => { + test(`GIVEN a carousel with loop disabled + WHEN on the last slide + THEN the next button should be disabled +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with loop disabled + WHEN on the first slide + THEN the previous button should be disabled +`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with loop enabled + WHEN on the last slide and the next button is clicked + THEN it should move to the first slide +`, async ({ page }) => { + // JACK HASNT DONE THIS YET + const { driver: d } = await setup(page, 'loop'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with loop enabled + WHEN on the first slide and the previous button is clicked + THEN it should move to the first slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + }); + + test(`GIVEN a carousel with a prop changing its initial index + WHEN rendered + THEN its scroll position should be at the initial index slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'initial'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test.describe('reactive', () => { + test(`GIVEN a carousel with a bind prop + WHEN rendered + THEN the selected slide should be at the initial index slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'reactive'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + + test(`GIVEN a carousel with a bind prop + WHEN the signal passed to bind changes + THEN the selected slide should be at the new signal value +`, async ({ page }) => { + const { driver: d } = await setup(page, 'reactive'); + + // remove this (there so that TS doesn't complain) + d; + + // TODO + }); + }); +}); + +// TODO: finish test cases, create new ones based on the expected behavior in Figma. + +// Getting a failing test when the test is expected to work helps us find bugs. + +// https://www.w3.org/WAI/ARIA/apg/patterns/carousel/ <-- another good resource for what functionality is expected + +/** + * + * When there is a use case that the default hero.tsx example doesn't cover, add a new test file in the docs headless/carousel/examples folder. + * + * + */ + +/** + * Future possible tests: + * Autoplay + * + * Snapping between center and end of slides + * + * Non-scroller or "conditional" carousels + * + * Multiple slides per view (2-n slides at a time) + * + * Multiple slider per move (+n slides per navigation) + * + */ diff --git a/packages/kit-headless/src/components/carousel/carousel.tsx b/packages/kit-headless/src/components/carousel/carousel.tsx deleted file mode 100644 index c4ece9c04..000000000 --- a/packages/kit-headless/src/components/carousel/carousel.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - type PropsOf, - type Signal, - Slot, - component$, - useContextProvider, - useSignal, -} from '@builder.io/qwik'; -import { type CarouselContext } from './carousel-context.type'; -import CarouselContextId from './carousel-context-id'; -import { VisuallyHidden } from '../../utils/visually-hidden'; - -export type CarouselRootProps = PropsOf<'section'> & { - spaceBetweenSlides?: number; - 'bind:currSlideIndex'?: Signal; -}; - -export const HCarousel = component$( - ({ - spaceBetweenSlides = 0, - 'bind:currSlideIndex': givenSlideIndexSig, - ...props - }: CarouselRootProps) => { - const defaultIndexSig = useSignal(0); - const currentIndexSig = givenSlideIndexSig ? givenSlideIndexSig : defaultIndexSig; - - const slideOffsetSig = useSignal(0); - const numSlidesSig = useSignal(0); - const transitionDurationSig = useSignal(0); - const viewportRef = useSignal(); - const containerRef = useSignal(); - const isDraggingSig = useSignal(false); - const initialX = useSignal(0); - const initialTransformX = useSignal(0); - const slideRefsArray = useSignal>([]); - - const context: CarouselContext = { - slideOffsetSig, - currentIndexSig, - numSlidesSig, - transitionDurationSig, - viewportRef, - containerRef, - spaceBetweenSlides, - isDraggingSig, - initialX, - initialTransformX, - slideRefsArray, - }; - - useContextProvider(CarouselContextId, context); - - return ( -
- - Slide {context.currentIndexSig.value} of - {context.numSlidesSig.value} - - -
- ); - }, -); diff --git a/packages/kit-headless/src/components/carousel/context.ts b/packages/kit-headless/src/components/carousel/context.ts new file mode 100644 index 000000000..e6b1d8589 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/context.ts @@ -0,0 +1,30 @@ +import { createContextId } from '@builder.io/qwik'; + +export const carouselContextId = createContextId('carousel-context'); + +import { type Signal } from '@builder.io/qwik'; + +export interface CarouselContext { + // core state + localId: string; + scrollerRef: Signal; + scrollStartRef: Signal; + nextButtonRef: Signal; + prevButtonRef: Signal; + isMouseDraggingSig: Signal; + slideRefsArray: Signal>; + bulletRefsArray: Signal>; + currentIndexSig: Signal; + isScrollerSig: Signal; + isAutoplaySig: Signal; + + // derived + numSlidesSig: Signal; + isDraggableSig: Signal; + slidesPerViewSig: Signal; + gapSig: Signal; + alignSig: Signal<'start' | 'center' | 'end'>; + isLoopSig: Signal; + autoPlayIntervalMsSig: Signal; + initialIndex: number | undefined; +} diff --git a/packages/kit-headless/src/components/carousel/driver.ts b/packages/kit-headless/src/components/carousel/driver.ts new file mode 100644 index 000000000..182557f07 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/driver.ts @@ -0,0 +1,47 @@ +import { Locator, Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator.locator('[data-qui-carousel]'); + }; + + const getNextButton = () => { + return getRoot().locator('[data-qui-carousel-next]'); + }; + + const getPrevButton = () => { + return getRoot().locator('[data-qui-carousel-prev]'); + }; + + const getContainer = () => { + return getRoot().locator('[data-qui-carousel-scroller]'); + }; + + const getSlideAt = (index: number) => { + return getContainer().locator(`[data-qui-carousel-slide]`).nth(index); + }; + + /** + * Wait for all animations within the given element and subtrees to finish + * See: https://github.com/microsoft/playwright/issues/15660#issuecomment-1184911658 + */ + function waitForAnimationEnd(selector: string) { + return getRoot() + .locator(selector) + .evaluate((element) => + Promise.all(element.getAnimations().map((animation) => animation.finished)), + ); + } + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getNextButton, + getPrevButton, + getContainer, + getSlideAt, + waitForAnimationEnd, + }; +} diff --git a/packages/kit-headless/src/components/carousel/index.ts b/packages/kit-headless/src/components/carousel/index.ts index 77f643715..e2b6ea364 100644 --- a/packages/kit-headless/src/components/carousel/index.ts +++ b/packages/kit-headless/src/components/carousel/index.ts @@ -1,7 +1,9 @@ -export { HCarousel as Root } from './carousel'; -export { HCarouselView as View } from './carousel-viewport'; -export { HCarouselContainer as Container } from './carousel-container'; -export { HCarouselSlide as Slide } from './slide'; -export { HCarouselPrev as Prev } from './prev-button'; -export { HCarouselNext as Next } from './next-button'; -export { HCarouselPagination as Pagination } from './pagination'; +export { CarouselRoot as Root } from './inline'; +export { CarouselScroller as Scroller } from './scroller'; +export { CarouselSlide as Slide } from './slide'; +export { CarouselPrevious as Previous } from './previous'; +export { CarouselNext as Next } from './next'; +export { CarouselPagination as Pagination } from './pagination'; +export { CarouselBullet as Bullet } from './bullet'; +export { CarouselTitle as Title } from './title'; +export { CarouselPlayer as Player } from './player'; diff --git a/packages/kit-headless/src/components/carousel/inline.tsx b/packages/kit-headless/src/components/carousel/inline.tsx new file mode 100644 index 000000000..3e9dd9959 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/inline.tsx @@ -0,0 +1,67 @@ +import { Component } from '@builder.io/qwik'; +import { CarouselBase, CarouselRootProps } from './root'; +import { Carousel } from '@qwik-ui/headless'; +import { findComponent, processChildren } from '../../utils/inline-component'; + +type InternalProps = { + value?: string; + /** + * @deprecated Use `slideComponent` instead. + */ + carouselSlideComponent?: typeof Carousel.Slide; + /** + * @deprecated Use `bulletComponent` instead. + */ + carouselBulletComponent?: typeof Carousel.Bullet; + + slideComponent?: typeof Carousel.Slide; + bulletComponent?: typeof Carousel.Bullet; + titleComponent?: typeof Carousel.Title; +}; + +export const CarouselRoot: Component = ( + props: CarouselRootProps & InternalProps, +) => { + const { + children, + carouselSlideComponent: GivenSlideOld, + carouselBulletComponent: GivenBulletOld, + slideComponent: GivenSlide, + bulletComponent: GivenBullet, + titleComponent: GivenTitle, + ...rest + } = props; + const Slide = GivenSlide || GivenSlideOld || Carousel.Slide; + const Bullet = GivenBullet || GivenBulletOld || Carousel.Bullet; + const Title = GivenTitle || Carousel.Title; + let currSlideIndex = 0; + let currBulletIndex = 0; + let numSlides = 0; + let isTitle = false; + + // code executes when the item component's shell is "seen" + findComponent(Slide, (slideProps) => { + slideProps._index = currSlideIndex; + + currSlideIndex++; + numSlides++; + }); + + findComponent(Bullet, (bulletProps) => { + bulletProps._index = currBulletIndex; + + currBulletIndex++; + }); + + findComponent(Title, () => { + isTitle = true; + }); + + processChildren(children); + + return ( + + {props.children} + + ); +}; diff --git a/packages/kit-headless/src/components/carousel/next-button.tsx b/packages/kit-headless/src/components/carousel/next-button.tsx deleted file mode 100644 index 23e975dc1..000000000 --- a/packages/kit-headless/src/components/carousel/next-button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Slot, component$, useContext } from '@builder.io/qwik'; -import { type CarouselButtonProps } from './types'; -import CarouselContextId from './carousel-context-id'; -import { VisuallyHidden } from '../../utils/visually-hidden'; - -export const HCarouselNext = component$((props: CarouselButtonProps) => { - const context = useContext(CarouselContextId); - - return ( - - ); -}); diff --git a/packages/kit-headless/src/components/carousel/next.tsx b/packages/kit-headless/src/components/carousel/next.tsx new file mode 100644 index 000000000..87b39d6e1 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/next.tsx @@ -0,0 +1,82 @@ +import { + PropsOf, + Slot, + component$, + useContext, + useTask$, + useSignal, + $, + useComputed$, +} from '@builder.io/qwik'; +import { carouselContextId } from './context'; + +export const CarouselNext = component$((props: PropsOf<'button'>) => { + const context = useContext(carouselContextId); + const isLastSlideInViewSig = useSignal(false); + const initialLoadSig = useSignal(true); + const isKeyboardFocusSig = useSignal(false); + const isLastScrollableIndexSig = useComputed$(() => { + return context.numSlidesSig.value - context.slidesPerViewSig.value; + }); + + const handleFocusPrev$ = $(() => { + if (context.isLoopSig.value) return; + + if (isKeyboardFocusSig.value && isLastSlideInViewSig.value) { + const activeElAtBlur = document.activeElement; + setTimeout(() => { + if (document.activeElement !== activeElAtBlur) return; + if (isLastScrollableIndexSig.value >= context.currentIndexSig.value) { + context.prevButtonRef.value?.focus(); + } + }, 2000); + } + isKeyboardFocusSig.value = false; + }); + + const handleKeyDown$ = $(() => { + if (!isLastScrollableIndexSig.value) return; + + isKeyboardFocusSig.value = true; + }); + + useTask$(function updateSlidesPerViewState({ track }) { + track(() => context.currentIndexSig.value); + + if (initialLoadSig.value) return; + + isLastSlideInViewSig.value = + context.currentIndexSig.value >= isLastScrollableIndexSig.value; + }); + + useTask$(() => { + initialLoadSig.value = false; + }); + + const handleClick$ = $(() => { + if ( + context.currentIndexSig.value >= isLastScrollableIndexSig.value && + context.isLoopSig.value + ) { + context.currentIndexSig.value = 0; + } else { + context.currentIndexSig.value++; + } + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/components/carousel/pagination.tsx b/packages/kit-headless/src/components/carousel/pagination.tsx index e31ea6cd8..685e6ad12 100644 --- a/packages/kit-headless/src/components/carousel/pagination.tsx +++ b/packages/kit-headless/src/components/carousel/pagination.tsx @@ -1,38 +1,11 @@ -import { type QRL, type PropsOf, component$, useContext } from '@builder.io/qwik'; -import CarouselContextId from './carousel-context-id'; -import { type JSX } from '@builder.io/qwik/jsx-runtime'; +import { type PropsOf, component$, Slot } from '@builder.io/qwik'; -/* Why we use the range util: -https://www.joshwcomeau.com/snippets/javascript/range/ -*/ +type CarouselPaginationProps = PropsOf<'div'>; -type CarouselPaginationProps = PropsOf<'div'> & { - renderBullet$?: QRL<(n: number) => JSX.Element>; -}; - -export const HCarouselPagination = component$( - ({ renderBullet$, ...props }: CarouselPaginationProps) => { - const context = useContext(CarouselContextId); - - return ( -
- {Array.from({ length: context.numSlidesSig.value }, (v, i) => i).map((num) => ( - <> - {renderBullet$ ? ( - renderBullet$(num) - ) : ( -
- {num} -
- )} - - ))} -
- ); - }, -); +export const CarouselPagination = component$(({ ...props }: CarouselPaginationProps) => { + return ( +
+ +
+ ); +}); diff --git a/packages/kit-headless/src/components/carousel/player.tsx b/packages/kit-headless/src/components/carousel/player.tsx new file mode 100644 index 000000000..884c7b88b --- /dev/null +++ b/packages/kit-headless/src/components/carousel/player.tsx @@ -0,0 +1,80 @@ +import { + component$, + PropsOf, + Slot, + useContext, + $, + useTask$, + useSignal, +} from '@builder.io/qwik'; +import { carouselContextId } from './context'; +import { isServer } from '@builder.io/qwik/build'; + +export const CarouselPlayer = component$((props: PropsOf<'button'>) => { + const context = useContext(carouselContextId); + const intervalIdSig = useSignal(); + const isReducedMotionSig = useSignal(false); + const hasChangeListenerSig = useSignal(false); + + const handleClick$ = $(() => { + context.isAutoplaySig.value = !context.isAutoplaySig.value; + }); + + const checkReducedMotion$ = $(async (e: MediaQueryListEvent) => { + isReducedMotionSig.value = e.matches; + if (e.matches) { + context.isAutoplaySig.value = false; + clearInterval(intervalIdSig.value); + } + }); + + useTask$(function handleReducedMotion({ track }) { + track(() => context.currentIndexSig.value); + + if (isServer) return; + + const mediaQueryList = window.matchMedia('(prefers-reduced-motion: reduce)'); + isReducedMotionSig.value = mediaQueryList.matches; + + if (!hasChangeListenerSig.value) { + mediaQueryList.addEventListener('change', checkReducedMotion$); + hasChangeListenerSig.value = true; + } + }); + + useTask$(function handleAutoplayProgress({ track }) { + track(() => context.isAutoplaySig.value); + + if (isReducedMotionSig.value) return; + + if (!context.isAutoplaySig.value) { + clearInterval(intervalIdSig.value); + return; + } + + const advanceSlideIndex$ = $(() => { + context.currentIndexSig.value = + (context.currentIndexSig.value + 1) % context.numSlidesSig.value; + }); + + intervalIdSig.value = setInterval( + advanceSlideIndex$, + context.autoPlayIntervalMsSig.value, + ); + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/components/carousel/prev-button.tsx b/packages/kit-headless/src/components/carousel/prev-button.tsx deleted file mode 100644 index 30e6247fe..000000000 --- a/packages/kit-headless/src/components/carousel/prev-button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Slot, component$, useContext } from '@builder.io/qwik'; -import { type CarouselButtonProps } from './types'; -import CarouselContextId from './carousel-context-id'; -import { VisuallyHidden } from '../../utils/visually-hidden'; - -export const HCarouselPrev = component$((props: CarouselButtonProps) => { - const context = useContext(CarouselContextId); - return ( - - ); -}); diff --git a/packages/kit-headless/src/components/carousel/previous.tsx b/packages/kit-headless/src/components/carousel/previous.tsx new file mode 100644 index 000000000..8e75998da --- /dev/null +++ b/packages/kit-headless/src/components/carousel/previous.tsx @@ -0,0 +1,50 @@ +import { PropsOf, Slot, component$, useContext, useSignal, $ } from '@builder.io/qwik'; +import { carouselContextId } from './context'; + +export const CarouselPrevious = component$((props: PropsOf<'button'>) => { + const context = useContext(carouselContextId); + const isKeyboardFocusSig = useSignal(false); + + const handleFocusNext$ = $(() => { + if (context.isLoopSig.value) return; + + if (isKeyboardFocusSig.value && context.currentIndexSig.value === 0) { + const activeElAtBlur = document.activeElement; + setTimeout(() => { + if (document.activeElement !== activeElAtBlur) return; + if (context.currentIndexSig.value === 0) { + context.nextButtonRef.value?.focus(); + } + }, 2000); + } + isKeyboardFocusSig.value = false; + }); + + const handleKeyDown$ = $(() => { + isKeyboardFocusSig.value = true; + }); + + const handleClick$ = $(() => { + if (context.currentIndexSig.value === 0 && context.isLoopSig.value) { + context.currentIndexSig.value = context.numSlidesSig.value - 1; + } else { + context.currentIndexSig.value--; + } + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/components/carousel/root.tsx b/packages/kit-headless/src/components/carousel/root.tsx new file mode 100644 index 000000000..def0bd160 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/root.tsx @@ -0,0 +1,134 @@ +import { + type PropsOf, + type Signal, + Slot, + component$, + useContextProvider, + useSignal, + useComputed$, + useId, +} from '@builder.io/qwik'; +import { CarouselContext, carouselContextId } from './context'; +import { useBoundSignal } from '../../utils/bound-signal'; + +export type CarouselRootProps = PropsOf<'div'> & { + /** The gap between slides */ + gap?: number; + + /** Number of slides to show at once */ + slidesPerView?: number; + + /** Whether the carousel is draggable */ + draggable?: boolean; + + /** Alignment of slides within the viewport */ + align?: 'start' | 'center' | 'end'; + + /** Whether the carousel should loop */ + loop?: boolean; + + /** Bind the selected index to a signal */ + 'bind:selectedIndex'?: Signal; + + /** change the initial index of the carousel on render */ + startIndex?: number; + + /** + * @deprecated Use bind:selectedIndex instead + * Bind the current slide index to a signal + */ + 'bind:currSlideIndex'?: Signal; + + /** Whether the carousel should autoplay */ + 'bind:autoplay'?: Signal; + + /** Time in milliseconds before the next slide plays during autoplay */ + autoPlayIntervalMs?: number; + + /** @internal Total number of slides */ + _numSlides?: number; + + /** @internal Whether this carousel has a title */ + _isTitle?: boolean; +}; + +export const CarouselBase = component$( + ({ + 'bind:currSlideIndex': givenOldSlideIndexSig, + 'bind:selectedIndex': givenSlideIndexSig, + 'bind:autoplay': givenAutoplaySig, + _isTitle: isTitle, + startIndex, + ...props + }: CarouselRootProps) => { + // core state + const localId = useId(); + const scrollerRef = useSignal(); + const nextButtonRef = useSignal(); + const prevButtonRef = useSignal(); + const scrollStartRef = useSignal(); + const isMouseDraggingSig = useSignal(false); + const slideRefsArray = useSignal>([]); + const bulletRefsArray = useSignal>([]); + const currentIndexSig = useBoundSignal( + givenSlideIndexSig ?? givenOldSlideIndexSig, + startIndex ?? 0, + ); + const isScrollerSig = useSignal(false); + const isAutoplaySig = useBoundSignal(givenAutoplaySig, false); + + // derived + const numSlidesSig = useComputed$(() => props._numSlides ?? 0); + const isDraggableSig = useComputed$(() => props.draggable ?? true); + const slidesPerViewSig = useComputed$(() => props.slidesPerView ?? 1); + const gapSig = useComputed$(() => props.gap ?? 0); + const alignSig = useComputed$(() => props.align ?? 'start'); + const isLoopSig = useComputed$(() => props.loop ?? false); + const autoPlayIntervalMsSig = useComputed$(() => props.autoPlayIntervalMs ?? 0); + + const titleId = `${localId}-title`; + + const context: CarouselContext = { + localId, + scrollerRef, + nextButtonRef, + prevButtonRef, + scrollStartRef, + isMouseDraggingSig, + slideRefsArray, + bulletRefsArray, + currentIndexSig, + isScrollerSig, + isAutoplaySig, + numSlidesSig, + isDraggableSig, + slidesPerViewSig, + gapSig, + alignSig, + isLoopSig, + autoPlayIntervalMsSig, + initialIndex: startIndex, + }; + + useContextProvider(carouselContextId, context); + + return ( +
+ +
+ ); + }, +); diff --git a/packages/kit-headless/src/components/carousel/scroller.tsx b/packages/kit-headless/src/components/carousel/scroller.tsx new file mode 100644 index 000000000..06ed4ac9a --- /dev/null +++ b/packages/kit-headless/src/components/carousel/scroller.tsx @@ -0,0 +1,192 @@ +import { + component$, + type PropsOf, + Slot, + useContext, + useSignal, + $, + useTask$, +} from '@builder.io/qwik'; +import { carouselContextId } from './context'; +import { useStyles$ } from '@builder.io/qwik'; +import styles from './carousel.css?inline'; +import { isServer } from '@builder.io/qwik/build'; + +type CarouselContainerProps = PropsOf<'div'>; + +export const CarouselScroller = component$((props: CarouselContainerProps) => { + const context = useContext(carouselContextId); + useStyles$(styles); + const startXSig = useSignal(); + const scrollLeftSig = useSignal(0); + const isMouseDownSig = useSignal(false); + const isMouseMovingSig = useSignal(false); + const isTouchDeviceSig = useSignal(false); + const isTouchMovingSig = useSignal(true); + const isTouchStartSig = useSignal(false); + + useTask$(() => { + context.isScrollerSig.value = true; + }); + + const getSlidePosition$ = $((index: number) => { + if (!context.scrollerRef.value) return 0; + const container = context.scrollerRef.value; + const slides = context.slideRefsArray.value; + let position = 0; + for (let i = 0; i < index; i++) { + if (slides[i].value) { + position += slides[i].value.getBoundingClientRect().width + context.gapSig.value; + } + } + + const alignment = context.alignSig.value; + if (alignment === 'center') { + position -= + (container.clientWidth - slides[index].value.getBoundingClientRect().width) / 2; + } else if (alignment === 'end') { + position -= + container.clientWidth - slides[index].value.getBoundingClientRect().width; + } + + return Math.max(0, position); + }); + + const handleMouseMove$ = $((e: MouseEvent) => { + if (!isMouseDownSig.value || startXSig.value === undefined) return; + if (!context.scrollerRef.value) return; + const x = e.pageX - context.scrollerRef.value.offsetLeft; + const dragSpeed = 1.75; + const walk = (x - startXSig.value) * dragSpeed; + context.scrollerRef.value.scrollLeft = scrollLeftSig.value - walk; + isMouseMovingSig.value = true; + }); + + const handleMouseSnap$ = $(async () => { + if (!context.scrollerRef.value) return; + isMouseDownSig.value = false; + window.removeEventListener('mousemove', handleMouseMove$); + + const container = context.scrollerRef.value; + const slides = context.slideRefsArray.value; + const containerScrollLeft = container.scrollLeft; + + 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); + if (distance < minDistance) { + closestIndex = i; + minDistance = distance; + } + } + + const dragSnapPosition = await getSlidePosition$(closestIndex); + + container.scrollTo({ + left: dragSnapPosition, + behavior: 'smooth', + }); + + context.currentIndexSig.value = closestIndex; + }); + + const handleMouseDown$ = $((e: MouseEvent) => { + if (!context.isDraggableSig.value) return; + if (!context.scrollerRef.value) return; + if (context.initialIndex && context.scrollStartRef.value) { + context.scrollStartRef.value.style.setProperty('--scroll-snap-align', 'none'); + } + + isMouseDownSig.value = true; + startXSig.value = e.pageX - context.scrollerRef.value.offsetLeft; + scrollLeftSig.value = context.scrollerRef.value.scrollLeft; + window.addEventListener('mousemove', handleMouseMove$); + window.addEventListener('mouseup', handleMouseSnap$); + isMouseMovingSig.value = false; + }); + + useTask$(async function snapWithoutDrag({ track }) { + track(() => context.currentIndexSig.value); + + if (isMouseMovingSig.value) { + isMouseMovingSig.value = false; + return; + } + + if (isTouchDeviceSig.value && isTouchMovingSig.value) return; + + if (!context.scrollerRef.value || isServer) return; + + context.scrollStartRef.value?.style.setProperty('--scroll-snap-align', 'none'); + + const nonDragSnapPosition = await getSlidePosition$(context.currentIndexSig.value); + + if (isMouseDownSig.value) return; + + context.scrollerRef.value.scrollTo({ + left: nonDragSnapPosition, + behavior: 'smooth', + }); + + window.removeEventListener('mousemove', handleMouseMove$); + }); + + const updateTouchDeviceIndex$ = $(() => { + if (!context.scrollerRef.value) return; + const container = context.scrollerRef.value; + const containerScrollLeft = container.scrollLeft; + const slides = context.slideRefsArray.value; + + let currentIndex = 0; + let minDistance = Infinity; + + slides.forEach((slideRef, index) => { + if (!slideRef.value) return; + const slideLeft = slideRef.value.offsetLeft; + const distance = Math.abs(containerScrollLeft - slideLeft); + if (distance < minDistance) { + minDistance = distance; + currentIndex = index; + } + }); + + if (context.currentIndexSig.value !== currentIndex) { + context.currentIndexSig.value = currentIndex; + } + }); + + return ( +
+ isTouchDeviceSig.value && isTouchMovingSig.value && updateTouchDeviceIndex$(), + ), + props.onScroll$, + ]} + onTouchMove$={() => { + isTouchMovingSig.value = true; + }} + onTouchStart$={() => { + isTouchStartSig.value = true; + }} + window:onTouchStart$={() => { + isTouchMovingSig.value = false; + isTouchDeviceSig.value = true; + }} + preventdefault:mousemove + data-align={context.alignSig.value} + data-initial-touch={isTouchStartSig.value ? '' : undefined} + {...props} + > + +
+ ); +}); diff --git a/packages/kit-headless/src/components/carousel/slide.tsx b/packages/kit-headless/src/components/carousel/slide.tsx index dca53fb1b..60ed1c259 100644 --- a/packages/kit-headless/src/components/carousel/slide.tsx +++ b/packages/kit-headless/src/components/carousel/slide.tsx @@ -5,115 +5,75 @@ import { useContext, useTask$, useSignal, + useComputed$, $, } from '@builder.io/qwik'; -import CarouselContextId from './carousel-context-id'; -import { isServer } from '@builder.io/qwik/build'; +import { carouselContextId } from './context'; -export type CarouselSlideProps = PropsOf<'div'>; +export type CarouselSlideProps = PropsOf<'div'> & { + _index?: number; +}; -export const HCarouselSlide = component$(({ ...props }: CarouselSlideProps) => { - const context = useContext(CarouselContextId); +export const CarouselSlide = component$(({ _index, ...props }: CarouselSlideProps) => { + const context = useContext(carouselContextId); const slideRef = useSignal(); - const localIndexSig = useSignal(null); - - const handlePointerUp$ = $(() => { - context.isDraggingSig.value = false; - - if (!context.containerRef.value || !slideRef.value) { - return; - } - - /* - TODO: figure out why a separate DOMMatrix is why dragging and the buttons work properly. - */ - const style = window.getComputedStyle(context.containerRef.value); - const matrix = new DOMMatrix(style.transform); - - const containerTranslateX = matrix.m41; - // How far to the left the slides container is shifted. - const absContainerTranslateX = Math.abs(containerTranslateX); - - if (!context.viewportRef.value) { - return; - } - - // How far the left edge of this slide is from the left of the slides container. - const slideSlideContainerLeftOffset = slideRef.value.offsetLeft; - // How far the right edge of this slide is from the left of the slides container - // (includes space between slide). - const slideRightEdgePos = - slideSlideContainerLeftOffset + - slideRef.value.offsetWidth + - context.spaceBetweenSlides; - - const carouselViewportWidth = context.viewportRef.value.offsetWidth; - const halfViewportWidth = carouselViewportWidth / 2; - - const isWithinBounds = - absContainerTranslateX > slideSlideContainerLeftOffset - halfViewportWidth && - absContainerTranslateX < slideRightEdgePos - halfViewportWidth; - - if (isWithinBounds) { - context.transitionDurationSig.value = 300; - - /* - we update here when mouse released (not when slide changes) - this is how it can "snap" back to the previous slide - */ - context.slideOffsetSig.value = slideSlideContainerLeftOffset * -1; - } + const slideId = `${context.localId}-${_index ?? -1}`; + const isVisibleSig = useComputed$(() => { + const start = context.currentIndexSig.value; + const end = start + context.slidesPerViewSig.value; + return _index !== undefined && _index >= start && _index < end; }); - - useTask$(() => { - // local index - localIndexSig.value = context.numSlidesSig.value; - - // TODO: Refactor this out and use array length instead - context.numSlidesSig.value++; - - context.slideRefsArray.value = [...context.slideRefsArray.value, slideRef]; - - return; + const isActiveSig = useComputed$(() => { + return context.currentIndexSig.value === _index; }); - - useTask$(({ track }) => { - track(() => context.isDraggingSig.value); - - if (isServer) return; - - context.isDraggingSig.value - ? window.addEventListener('pointerup', handlePointerUp$) - : window.removeEventListener('pointerup', handlePointerUp$); + /** Used to hide the actual slide when it's inactive */ + const isInactiveSig = useComputed$(() => { + return !context.isScrollerSig.value && !isActiveSig.value; }); - useTask$(({ track }) => { - track(() => context.currentIndexSig.value); - if (localIndexSig.value === context.currentIndexSig.value && slideRef.value) { - context.transitionDurationSig.value = 625; - context.slideOffsetSig.value = slideRef.value.offsetLeft * -1; + useTask$(function getIndexOrder() { + if (_index !== undefined) { + context.slideRefsArray.value[_index] = slideRef; + } else { + throw new Error('Qwik UI: Carousel Slide cannot find its proper index.'); } + }); - /* TODO: figure out how to customize animation for separate actions: - - For example, this 625 is now for everything, because the slide index changing is our source of truth. - - Perhaps a bind? - - */ - setTimeout(() => { - context.transitionDurationSig.value = 625; - }, 0); + const handleFocusIn$ = $(() => { + context.isAutoplaySig.value = false; }); return ( -
- -
+ <> + {/* start marker */} + {context.initialIndex === _index && context.alignSig.value === 'start' && ( +
+ )} + + + {/* end marker */} + {context.initialIndex === _index && context.alignSig.value === 'end' && ( +
+ )} + ); }); diff --git a/packages/kit-headless/src/components/carousel/title.tsx b/packages/kit-headless/src/components/carousel/title.tsx new file mode 100644 index 000000000..cd1b43771 --- /dev/null +++ b/packages/kit-headless/src/components/carousel/title.tsx @@ -0,0 +1,14 @@ +import { component$, Slot, useContext } from '@builder.io/qwik'; +import { carouselContextId } from './context'; + +/** Used to distinguish accessible label from other carousels */ +export const CarouselTitle = component$(() => { + const context = useContext(carouselContextId); + const titleId = `${context.localId}-title`; + + return ( +
+ +
+ ); +}); diff --git a/packages/kit-headless/src/components/carousel/types.ts b/packages/kit-headless/src/components/carousel/types.ts deleted file mode 100644 index c7a3999f0..000000000 --- a/packages/kit-headless/src/components/carousel/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { type PropsOf } from '@builder.io/qwik'; - -export type CarouselButtonProps = PropsOf<'button'>; diff --git a/packages/kit-headless/src/components/carousel/utils.ts b/packages/kit-headless/src/components/carousel/utils.ts deleted file mode 100644 index c350b8948..000000000 --- a/packages/kit-headless/src/components/carousel/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function getContainerTranslateX( - containerElement: HTMLDivElement, - e: PointerEvent, -): number { - const style = window.getComputedStyle(containerElement); - const matrix = new DOMMatrix(style.transform); - return matrix.m41 + e.movementX; -} diff --git a/packages/kit-headless/src/components/collapsible/collapsible.tsx b/packages/kit-headless/src/components/collapsible/collapsible.tsx index 5ac2f2d0e..aafe8e107 100644 --- a/packages/kit-headless/src/components/collapsible/collapsible.tsx +++ b/packages/kit-headless/src/components/collapsible/collapsible.tsx @@ -113,6 +113,7 @@ export const HCollapsible = component$((props: CollapsibleProps) => { data-disabled={context.disabled ? '' : undefined} data-open={context.isOpenSig.value ? '' : undefined} data-closed={!context.isOpenSig.value ? '' : undefined} + aria-live="polite" {...rest} > diff --git a/packages/kit-headless/src/utils/bound-signal.tsx b/packages/kit-headless/src/utils/bound-signal.tsx new file mode 100644 index 000000000..b746450a4 --- /dev/null +++ b/packages/kit-headless/src/utils/bound-signal.tsx @@ -0,0 +1,30 @@ +import { useSignal, useTask$, type Signal } from '@builder.io/qwik'; + +/** + * Creates a bound signal that synchronizes with an external signal if provided. + * This hook is useful for two-way binding scenarios, especially when dealing with + * component props that may or may not be signals. + * + * @param givenSignal - An optional external signal to bind to. + * @param initialValue - The initial value to use if no external signal is provided. + * @returns A signal that is either bound to the external signal or a new internal signal. + * + * The returned signal will update the external signal (if provided) whenever its value changes, + * and will also update itself when the external signal changes. + */ +export function useBoundSignal(givenSignal?: Signal, initialValue?: T): Signal { + const internalSignal = useSignal(givenSignal?.value ?? (initialValue as T)); + const boundSignal = givenSignal ?? internalSignal; + + useTask$(({ track }) => { + const value = track(() => boundSignal.value); + if (givenSignal && givenSignal !== boundSignal) { + givenSignal.value = value; + } + if (boundSignal !== internalSignal) { + internalSignal.value = value; + } + }); + + return boundSignal; +} diff --git a/packages/kit-headless/src/utils/visually-hidden.tsx b/packages/kit-headless/src/utils/visually-hidden.tsx index d67123c88..e3eba8aea 100644 --- a/packages/kit-headless/src/utils/visually-hidden.tsx +++ b/packages/kit-headless/src/utils/visually-hidden.tsx @@ -6,22 +6,12 @@ export const VisuallyHidden = component$((props: PropsOf<'span'>) => { useStylesScoped$(` .visually-hidden:not(:focus):not(:active) { - /* shrink to a 1px square */ - width: 1px; - height: 1px; - - /* hide any resulting overflow */ - overflow: hidden; - - /* clip the element to remove any visual trace */ - clip: rect(0 0 0 0); /* for IE only */ - clip-path: inset(50%); - - /* remove from page flow so it doesn't affect surrounding layout */ - position: absolute; - - /* ensure proper text announcement by screen readers */ - white-space: nowrap; + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; } `); diff --git a/packages/kit-headless/tsconfig.editor.json b/packages/kit-headless/tsconfig.editor.json index ad23ac774..0a701cad9 100644 --- a/packages/kit-headless/tsconfig.editor.json +++ b/packages/kit-headless/tsconfig.editor.json @@ -1,6 +1,11 @@ { "extends": "./tsconfig.json", - "include": ["**/*.ts", "**/*.tsx", "src/components/popover/polyfill/popover.js"], + "include": [ + "**/*.ts", + "**/*.tsx", + "src/components/popover/polyfill/popover.js", + "src/components/carousel/carousel.test.ts" + ], "compilerOptions": { "types": ["node", "jest", "@testing-library/jest-dom", "@testing-library/cypress"] } diff --git a/packages/kit-headless/tsconfig.lib.json b/packages/kit-headless/tsconfig.lib.json index 350c6bb9c..60d26e8a7 100644 --- a/packages/kit-headless/tsconfig.lib.json +++ b/packages/kit-headless/tsconfig.lib.json @@ -17,5 +17,11 @@ "**/*.spec.ts", "**/*.driver.ts" ], - "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] + "include": [ + "**/*.js", + "**/*.jsx", + "**/*.ts", + "**/*.tsx", + "src/components/carousel/carousel.test.ts" + ] } diff --git a/packages/kit-styled/package.json b/packages/kit-styled/package.json index a07abbcda..4dbdade97 100644 --- a/packages/kit-styled/package.json +++ b/packages/kit-styled/package.json @@ -20,7 +20,7 @@ "private": false, "scripts": {}, "peerDependencies": { - "@builder.io/qwik": "1.7.0" + "@builder.io/qwik": "1.7.2" }, "devDependencies": { "@qwik-ui/headless": "^0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5408f9de..6b3a0f89d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^4.9.1 version: 4.9.1(playwright-core@1.44.1) '@builder.io/qwik': - specifier: 1.7.0 - version: 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4) + specifier: ^1.7.2 + version: 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) '@builder.io/qwik-city': - specifier: 1.7.0 - version: 1.7.0(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1) + specifier: ^1.7.2 + version: 1.7.2(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1) '@changesets/cli': specifier: ^2.27.3 version: 2.27.5 @@ -55,7 +55,7 @@ importers: version: 3.0.0-feat-sst-upgrade.1(@nx/devkit@19.4.2(nx@19.4.2(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.11))(@swc/types@0.1.9)(typescript@5.4.5))(@swc/core@1.5.29(@swc/helpers@0.5.11))))(@nx/node@19.3.0(@babel/traverse@7.24.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.11))(@swc/types@0.1.9)(typescript@5.4.5))(@swc/core@1.5.29(@swc/helpers@0.5.11))(@types/node@20.12.12)(@zkochan/js-yaml@0.0.7)(eslint@8.57.0)(nx@19.4.2(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.11))(@swc/types@0.1.9)(typescript@5.4.5))(@swc/core@1.5.29(@swc/helpers@0.5.11)))(ts-node@10.9.1(@swc/core@1.5.29(@swc/helpers@0.5.11))(@types/node@20.12.12)(typescript@5.4.5))(typescript@5.4.5)(verdaccio@5.31.0(typanion@3.14.0)))(esbuild@0.17.19)(wrangler@3.58.0) '@modular-forms/qwik': specifier: ^0.24.0 - version: 0.24.0(@builder.io/qwik-city@1.7.0(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1))(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4)) + version: 0.24.0(@builder.io/qwik-city@1.7.2(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1))(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)) '@nx/cypress': specifier: 19.4.2 version: 19.4.2(@babel/traverse@7.24.6)(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.11))(@swc/types@0.1.9)(typescript@5.4.5))(@swc/core@1.5.29(@swc/helpers@0.5.11))(@types/node@20.12.12)(@zkochan/js-yaml@0.0.7)(cypress@13.13.0)(eslint@8.57.0)(nx@19.4.2(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.11))(@swc/types@0.1.9)(typescript@5.4.5))(@swc/core@1.5.29(@swc/helpers@0.5.11)))(typescript@5.4.5)(verdaccio@5.31.0(typanion@3.14.0)) @@ -94,7 +94,7 @@ importers: version: 1.44.1 '@qwikest/icons': specifier: ^0.0.13 - version: 0.0.13(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4)) + version: 0.0.13(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)) '@shikijs/transformers': specifier: ^1.11.0 version: 1.11.0 @@ -169,7 +169,7 @@ importers: version: 1.5.0(axe-core@4.9.1)(cypress@13.13.0) cypress-ct-qwik: specifier: 0.3.0 - version: 0.3.0(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4))(cypress@13.13.0) + version: 0.3.0(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1))(cypress@13.13.0) cypress-real-events: specifier: 1.12.0 version: 1.12.0(cypress@13.13.0) @@ -195,8 +195,8 @@ importers: specifier: ^1.6.2 version: 1.6.2(eslint@8.57.0) eslint-plugin-qwik: - specifier: ^1.7.0 - version: 1.7.0(eslint@8.57.0) + specifier: ^1.7.2 + version: 1.7.2(eslint@8.57.0) focus-trap: specifier: 7.5.4 version: 7.5.4 @@ -343,8 +343,8 @@ importers: packages/kit-headless: dependencies: '@builder.io/qwik': - specifier: 1.7.0 - version: 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@6.18.2) + specifier: 1.7.2 + version: 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) '@floating-ui/core': specifier: ^1.6.2 version: 1.6.2 @@ -364,8 +364,8 @@ importers: packages/kit-styled: dependencies: '@builder.io/qwik': - specifier: 1.7.0 - version: 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@6.18.2) + specifier: 1.7.2 + version: 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) devDependencies: '@qwik-ui/headless': specifier: ^0.5.0 @@ -1033,16 +1033,14 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@builder.io/qwik-city@1.7.0': - resolution: {integrity: sha512-neufbMgSpr2NhM3EFXsggJdFxVtvw/381pH8mFbA+v041megxFDyFVSsCrI+Dud4zBs96trz7pm4hzeweDb0Ng==} + '@builder.io/qwik-city@1.7.2': + resolution: {integrity: sha512-b7qy6sKcPzxlwI/KshbmtpRGUML3EBXsU8IoCOH9tRoOqKTrhqfg+vAIsh+f8FslXTt1oVF9tL6KYlpu+ZANnA==} engines: {node: '>=16.8.0 <18.0.0 || >=18.11'} - '@builder.io/qwik@1.7.0': - resolution: {integrity: sha512-AF6P3VHxF6VtJaeVQsNT9SjGp0PFpQuKbwiTdVcGOf5ischVEz+PZfaRHQBDCupKg0wQl0Mn/4PYDYp0WVcJIg==} + '@builder.io/qwik@1.7.2': + resolution: {integrity: sha512-9pMZHDB6hIHn6jfQu0zRT54G7Lg0gSGVlGSogYttdeNB3qKl9QkuHXSz3wziyBD6mtFMrCkpLshFs/ZBtajoIA==} engines: {node: '>=16.8.0 <18.0.0 || >=18.11'} hasBin: true - peerDependencies: - undici: '*' '@changesets/apply-release-plan@7.0.3': resolution: {integrity: sha512-klL6LCdmfbEe9oyfLxnidIf/stFXmrbFO/3gT5LU5pcyoZytzJe4gWpTBx3BPmyNPl16dZ1xrkcW7b98e3tYkA==} @@ -4399,8 +4397,8 @@ packages: eslint-plugin-jest: optional: true - eslint-plugin-qwik@1.7.0: - resolution: {integrity: sha512-ZggDtq1TnYRiReq0vEKuIX2ojXc2eQlgnRT0gUlxcsqBOCCOLTBO2bYtrF8KTxP3D2RemcdwHlGppLN4/+LzDw==} + eslint-plugin-qwik@1.7.2: + resolution: {integrity: sha512-2iZcfKZluZPk+LsC0+sYgFU6eH9oPcL2Ev0ZwJXuk+87b8vNDLunqlC0tEiHp57z7e7d22cuctGZ0if4LxzgUw==} engines: {node: '>=16.8.0 <18.0.0 || >=18.11'} peerDependencies: eslint: ^8.57.0 @@ -8246,10 +8244,6 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - undici@6.18.2: - resolution: {integrity: sha512-o/MQLTwRm9IVhOqhZ0NQ9oXax1ygPjw6Vs+Vq/4QRjbOAC3B1GCHy7TYxxbExKlb7bzDRzt9vBWU6BDz0RFfYg==} - engines: {node: '>=18.17'} - unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -9562,7 +9556,7 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@builder.io/qwik-city@1.7.0(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1)': + '@builder.io/qwik-city@1.7.2(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1)': dependencies: '@mdx-js/mdx': 3.0.1 '@types/mdx': 2.0.13 @@ -9584,24 +9578,9 @@ snapshots: - supports-color - terser - '@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4)': - dependencies: - csstype: 3.1.3 - undici: 5.28.4 - vite: 5.2.11(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - stylus - - sugarss - - terser - - '@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@6.18.2)': + '@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)': dependencies: csstype: 3.1.3 - undici: 6.18.2 vite: 5.2.11(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) transitivePeerDependencies: - '@types/node' @@ -10503,10 +10482,10 @@ snapshots: '@microsoft/tsdoc@0.14.2': {} - '@modular-forms/qwik@0.24.0(@builder.io/qwik-city@1.7.0(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1))(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4))': + '@modular-forms/qwik@0.24.0(@builder.io/qwik-city@1.7.2(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1))(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1))': dependencies: - '@builder.io/qwik': 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4) - '@builder.io/qwik-city': 1.7.0(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1) + '@builder.io/qwik': 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) + '@builder.io/qwik-city': 1.7.2(@types/node@20.12.12)(rollup@4.18.0)(sass@1.77.4)(terser@5.31.1) decode-formdata: 0.7.3 '@nodelib/fs.scandir@2.1.5': @@ -11362,9 +11341,9 @@ snapshots: '@polka/url@1.0.0-next.25': {} - '@qwikest/icons@0.0.13(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4))': + '@qwikest/icons@0.0.13(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1))': dependencies: - '@builder.io/qwik': 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4) + '@builder.io/qwik': 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) '@rollup/pluginutils@4.2.1': dependencies: @@ -13318,9 +13297,9 @@ snapshots: axe-core: 4.9.1 cypress: 13.13.0 - cypress-ct-qwik@0.3.0(@builder.io/qwik@1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4))(cypress@13.13.0): + cypress-ct-qwik@0.3.0(@builder.io/qwik@1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1))(cypress@13.13.0): dependencies: - '@builder.io/qwik': 1.7.0(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1)(undici@5.28.4) + '@builder.io/qwik': 1.7.2(@types/node@20.12.12)(sass@1.77.4)(terser@5.31.1) '@cypress/mount-utils': 4.1.0 cypress: 13.13.0 @@ -13971,7 +13950,7 @@ snapshots: eslint: 8.57.0 globals: 13.24.0 - eslint-plugin-qwik@1.7.0(eslint@8.57.0): + eslint-plugin-qwik@1.7.2(eslint@8.57.0): dependencies: eslint: 8.57.0 jsx-ast-utils: 3.3.5 @@ -18609,8 +18588,6 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@6.18.2: {} - unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-match-property-ecmascript@2.0.0: