From f83d82dcbfa76ef04a1d7b4cf8eb9597c662c981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=AFeul?= <45822175+maiieul@users.noreply.github.com> Date: Wed, 21 Aug 2024 22:25:11 +0200 Subject: [PATCH 01/27] move to SSG adapter (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * allow people to pass handlers to modal close * moving to the static adapter * Preview (#940) * chore(website): bump to qwik 1.7.3 + entry files output (#926) * Revert-new-sw (#927) * Revert "change to new sw impl" This reverts commit 3081504323255d76ebdae5bcad45a267d22ebd4e. * chore(website): charSet -> charset in root.tsx * Remove-import-meta-glob-eager-true (#928) * chore(website): remove import.meta.glob eager true * chore(website): comment out vite config trick to se chunk names * chore(pkg.pr.new): remove 0.0.9 flag (#921) * chore(qwik-themes): move code internally + signal implementation (#922) * chore(qwik-themes): move code internally under _state folder * chore(themes): linting * fix(themes): use signals instead of stores * chore(themes provider): useVisibleTask to useOnWindow * chore(themes): move to qwik-ui/themes * Tooltip Beta (#934) * feat(tooltip): implement tooltip to beta phase * fix(tooltip): small tweaks to the tooltip state * fix: remove animations form tooltip docs and fix placement example * feat(tooltip): implement onOpenChange$ * chore: fix changeset type * test: update placement test to remove loop * fix: remove breaking examples and tooltip route * fix: checkbox tests preventing us from opening pw * refactor: deprecate popover hover prop in favor of tooltip * latest --------- Co-authored-by: Christopher Woolum * fix: comment out bundle issue when changing user pref (#935) * chore(website): disable qwikVite linter to speed up preview and builds (#936) * Inline comp docs (#931) * allow people to pass handlers to modal close * docs: improve contributor guide * docs: mention inline components * add utils changeset (#937) * Version Packages (#917) Co-authored-by: github-actions[bot] * chore(/themes): turn visible task to strategy:document-idle (#938) * chore(/themes): turn visible task to strategy:document-idle * fix(themes): add timeout to themeSig assignment to localstorage * fix(themes): augment timeout to themeSig assignment to localstorage * fix(website): remove js execution on load by assigning themes sig on click (#939) * feat: static adapter * use improved shiki node version now that we're on static --------- Co-authored-by: Maïeul <45822175+maiieul@users.noreply.github.com> Co-authored-by: Christopher Woolum Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: maiieul * fix(website): click on copy code --------- Co-authored-by: jack shelton Co-authored-by: Jack Shelton <104264123+thejackshelton@users.noreply.github.com> Co-authored-by: Christopher Woolum Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- apps/website/README.md | 14 +++++--- .../adapters/cloudflare-pages/vite.config.ts | 22 ------------- apps/website/adapters/static/vite.config.ts | 19 +++++++++++ apps/website/project.json | 2 +- .../src/components/highlight/highlight.tsx | 33 ++++++++----------- apps/website/vite.config.ts | 5 +++ 6 files changed, 49 insertions(+), 46 deletions(-) delete mode 100644 apps/website/adapters/cloudflare-pages/vite.config.ts create mode 100644 apps/website/adapters/static/vite.config.ts diff --git a/apps/website/README.md b/apps/website/README.md index 3705c3c6a..69c7f279d 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -35,7 +35,7 @@ Inside your project, you'll see the following directory structure: Use the `pnpm qwik add` command to add additional integrations. Some examples of integrations include: Cloudflare, Netlify or Express server, and the [Static Site Generator (SSG)](https://qwik.builder.io/qwikcity/static-site-generation/static-site-config/). ```shell -pnpm qwik add # or `yarn qwik add` +pnpm qwik add # or `pnpm qwik add` ``` ## Development @@ -43,7 +43,7 @@ pnpm qwik add # or `yarn qwik add` Development mode uses [Vite's development server](https://vitejs.dev/). During development, the `dev` command will server-side render (SSR) the output. ```shell -npm start # or `yarn start` +npm start # or `pnpm start` ``` > Note: during dev mode, Vite may request a significant number of `.js` files. This does not represent a Qwik production build. @@ -53,7 +53,7 @@ npm start # or `yarn start` The preview command will create a production build of the client modules, a production build of `src/entry.preview.tsx`, and run a local server. The preview server is only for convenience to locally preview a production build, and it should not be used as a production server. ```shell -pnpm preview # or `yarn preview` +pnpm preview # or `pnpm preview` ``` ## Production @@ -61,5 +61,11 @@ pnpm preview # or `yarn preview` The production build will generate client and server modules by running both client and server build commands. Additionally, the build command will use Typescript to run a type check on the source code. ```shell -pnpm build # or `yarn build` +pnpm build # or `pnpm build` +``` + +## Static Site Generator (Node.js) + +```shell +pnpm build.server ``` diff --git a/apps/website/adapters/cloudflare-pages/vite.config.ts b/apps/website/adapters/cloudflare-pages/vite.config.ts deleted file mode 100644 index 8f3036425..000000000 --- a/apps/website/adapters/cloudflare-pages/vite.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { cloudflarePagesAdapter } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite'; -import { extendConfig } from '@builder.io/qwik-city/vite'; -import baseConfig from '../../vite.config'; - -export default extendConfig(baseConfig, () => { - return { - build: { - ssr: true, - rollupOptions: { - input: ['apps/website/src/entry.cloudflare-pages.tsx', '@qwik-city-plan'], - }, - }, - plugins: [ - cloudflarePagesAdapter({ - ssg: { - include: ['/*'], - origin: 'https://qwikui.com', - }, - }), - ], - }; -}); diff --git a/apps/website/adapters/static/vite.config.ts b/apps/website/adapters/static/vite.config.ts new file mode 100644 index 000000000..5a0a297d1 --- /dev/null +++ b/apps/website/adapters/static/vite.config.ts @@ -0,0 +1,19 @@ +import { staticAdapter } from '@builder.io/qwik-city/adapters/static/vite'; +import { extendConfig } from '@builder.io/qwik-city/vite'; +import baseConfig from '../../vite.config'; + +export default extendConfig(baseConfig, () => { + return { + build: { + ssr: true, + rollupOptions: { + input: ['@qwik-city-plan'], + }, + }, + plugins: [ + staticAdapter({ + origin: 'https://qwikui.com', + }), + ], + }; +}); diff --git a/apps/website/project.json b/apps/website/project.json index b1aa7c9b6..aff989143 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -47,7 +47,7 @@ "mode": "production" }, "production": { - "configFile": "apps/website/adapters/cloudflare-pages/vite.config.ts" + "configFile": "apps/website/adapters/static/vite.config.ts" } }, "dependsOn": [] diff --git a/apps/website/src/components/highlight/highlight.tsx b/apps/website/src/components/highlight/highlight.tsx index 06bed0143..5bc47d5be 100644 --- a/apps/website/src/components/highlight/highlight.tsx +++ b/apps/website/src/components/highlight/highlight.tsx @@ -1,23 +1,12 @@ -import { ClassList, PropsOf, component$, useSignal, useTask$ } from '@builder.io/qwik'; +import { ClassList, PropsOf, component$, useSignal, useTask$, $ } from '@builder.io/qwik'; import { CodeCopy } from '../code-copy/code-copy'; import { cn } from '@qwik-ui/utils'; -import poimandres from 'shiki/themes/poimandres.mjs'; -import html from 'shiki/langs/html.mjs'; -import css from 'shiki/langs/css.mjs'; -import tsx from 'shiki/langs/tsx.mjs'; -import { createHighlighterCore, BundledLanguage } from 'shiki/index.mjs'; - -// Create a single highlighter instance -const highlighterPromise = createHighlighterCore({ - themes: [poimandres], - langs: [html, css, tsx], - loadWasm: import('shiki/wasm'), -}); +import { codeToHtml } from 'shiki'; export type HighlightProps = PropsOf<'div'> & { code: string; copyCodeClass?: ClassList; - language?: BundledLanguage; + language?: 'tsx' | 'html' | 'css'; splitCommentStart?: string; splitCommentEnd?: string; }; @@ -33,26 +22,32 @@ export const Highlight = component$( }: HighlightProps) => { const codeSig = useSignal(''); - useTask$(async ({ track }) => { - track(() => code); + const addShiki$ = $(async () => { let modifiedCode: string = code; let partsOfCode = modifiedCode.split(splitCommentStart); + if (partsOfCode.length > 1) { modifiedCode = partsOfCode[1]; } partsOfCode = modifiedCode.split(splitCommentEnd); + if (partsOfCode.length > 1) { modifiedCode = partsOfCode[0]; } - const highlighter = await highlighterPromise; - const str = highlighter.codeToHtml(modifiedCode, { + const str = await codeToHtml(modifiedCode, { lang: language, theme: 'poimandres', }); - codeSig.value = str; + + codeSig.value = str.toString(); + }); + + useTask$(async ({ track }) => { + track(() => code); + await addShiki$(); }); return ( diff --git a/apps/website/vite.config.ts b/apps/website/vite.config.ts index 19fe368ec..7d3229ee8 100644 --- a/apps/website/vite.config.ts +++ b/apps/website/vite.config.ts @@ -105,5 +105,10 @@ export default defineConfig(async () => { 'Cache-Control': 'public, max-age=600', }, }, + optimizeDeps: { + // Put problematic deps that break bundling here, mostly those with binaries. + // For example ['better-sqlite3'] if you use that in server functions. + exclude: ['shiki'], + }, }; }); From b78cce6cfd93249228a6356940f642dea7cca0c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 20:28:25 +0000 Subject: [PATCH 02/27] @ArkadiK94 has signed the CLA from Pull Request #948 --- cla-signs/v1/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cla-signs/v1/cla.json b/cla-signs/v1/cla.json index db893252b..8ad8beb1c 100644 --- a/cla-signs/v1/cla.json +++ b/cla-signs/v1/cla.json @@ -551,6 +551,14 @@ "created_at": "2024-08-08T20:45:05Z", "repoId": 545159943, "pullRequestNo": 916 + }, + { + "name": "ArkadiK94", + "id": 76536506, + "comment_id": 2311027894, + "created_at": "2024-08-26T20:28:12Z", + "repoId": 545159943, + "pullRequestNo": 948 } ] } \ No newline at end of file From e1db6c2c6ba2b3379350b3a30a7c17ac49ba0caf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:31:55 +0000 Subject: [PATCH 03/27] @steffanek has signed the CLA from Pull Request #957 --- cla-signs/v1/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cla-signs/v1/cla.json b/cla-signs/v1/cla.json index 8ad8beb1c..72d09f8e6 100644 --- a/cla-signs/v1/cla.json +++ b/cla-signs/v1/cla.json @@ -559,6 +559,14 @@ "created_at": "2024-08-26T20:28:12Z", "repoId": 545159943, "pullRequestNo": 948 + }, + { + "name": "steffanek", + "id": 22999414, + "comment_id": 2325233734, + "created_at": "2024-09-02T19:31:42Z", + "repoId": 545159943, + "pullRequestNo": 957 } ] } \ No newline at end of file From fca032d1c4556ff630e9e5325e2d40f1dd1806a7 Mon Sep 17 00:00:00 2001 From: Jack Shelton <104264123+thejackshelton@users.noreply.github.com> Date: Mon, 2 Sep 2024 23:58:16 -0500 Subject: [PATCH 04/27] Refactored ProgressBar + Carousel Stepper (#952) * allow people to pass handlers to modal close * refactored progress / backwards compatible * get example working again * feat: progress bar accounts for any range and computes a progress * initial stepper * feat: indeterminate * feat: indeterminate animation code snippet * feat: improve progress examples * docs: finish progress docs * feat: stepper * tests: initial tests * refactor: carousel test reorganization * refactor: remove test numbers * tests: add multiple slides tests * tests: add non-draggable test * feat: mobile tests * feat: enabled and disabled tests * test: slide snapping * test: csr * test: autoplay tests * test: add state tests * test: stepper tests * docs: carousel docs * autoplay and last stepper tests --- .../headless/carousel/examples/carousel.css | 30 +- .../headless/carousel/examples/progress.tsx | 38 + .../carousel/examples/stepper-no-scroll.tsx | 22 + .../examples/stepper-presentational.tsx | 26 + .../headless/carousel/examples/stepper.tsx | 24 + .../carousel/examples/test-player-visible.tsx | 46 + .../carousel/examples/vertical-stepper.tsx | 26 + .../routes/docs/headless/carousel/index.mdx | 144 ++- .../headless/progress/examples/complete.tsx | 15 + .../docs/headless/progress/examples/csr.tsx | 23 + .../docs/headless/progress/examples/hero.tsx | 11 +- .../progress/examples/indeterminate.tsx | 17 + .../docs/headless/progress/examples/max.tsx | 48 + .../docs/headless/progress/examples/min.tsx | 53 + .../headless/progress/examples/reactive.tsx | 20 + .../routes/docs/headless/progress/index.mdx | 103 +- .../progress/snippets/indeterminate.css | 15 + .../headless/progress/snippets/progress.css | 37 + .../docs/styled/progress/examples/hero.tsx | 4 +- .../src/components/carousel/actionable.md | 21 - .../src/components/carousel/carousel.test.ts | 912 +++++++++++++----- .../src/components/carousel/context.ts | 13 +- .../src/components/carousel/driver.ts | 80 +- .../src/components/carousel/index.ts | 2 + .../src/components/carousel/inline.tsx | 10 + .../src/components/carousel/next.tsx | 2 +- .../src/components/carousel/player.tsx | 57 +- .../src/components/carousel/previous.tsx | 2 +- .../src/components/carousel/root.tsx | 32 +- .../src/components/carousel/scroller.tsx | 2 +- .../src/components/carousel/slide.tsx | 6 +- .../src/components/carousel/step.tsx | 50 + .../src/components/carousel/stepper.tsx | 9 + .../src/components/carousel/use-carousel.tsx | 52 + .../src/components/polymorphic/index.ts | 1 + .../components/polymorphic/polymorphic.tsx | 19 + .../src/components/progress/index.ts | 4 +- .../components/progress/progress-context.ts | 8 +- .../progress/progress-indicator.tsx | 22 +- .../src/components/progress/progress-root.tsx | 111 +++ .../src/components/progress/progress.test.ts | 84 +- .../src/components/progress/progress.tsx | 57 -- .../src/components/progress/util.ts | 24 - packages/kit-headless/src/index.ts | 1 + .../src/components/progress/progress.tsx | 11 +- 45 files changed, 1759 insertions(+), 535 deletions(-) create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/progress.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/stepper-no-scroll.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/stepper-presentational.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/stepper.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/test-player-visible.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/vertical-stepper.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/complete.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/csr.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/indeterminate.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/max.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/min.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/examples/reactive.tsx create mode 100644 apps/website/src/routes/docs/headless/progress/snippets/indeterminate.css delete mode 100644 packages/kit-headless/src/components/carousel/actionable.md create mode 100644 packages/kit-headless/src/components/carousel/step.tsx create mode 100644 packages/kit-headless/src/components/carousel/stepper.tsx create mode 100644 packages/kit-headless/src/components/carousel/use-carousel.tsx create mode 100644 packages/kit-headless/src/components/polymorphic/index.ts create mode 100644 packages/kit-headless/src/components/polymorphic/polymorphic.tsx create mode 100644 packages/kit-headless/src/components/progress/progress-root.tsx delete mode 100644 packages/kit-headless/src/components/progress/progress.tsx delete mode 100644 packages/kit-headless/src/components/progress/util.ts 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 92a4a26fe..05e3ad931 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css +++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css @@ -18,7 +18,6 @@ gap: 0.5rem; padding: 1rem; border: 2px dotted hsl(var(--foreground)); - outline: none; } .carousel-buttons { @@ -43,6 +42,7 @@ .carousel-pagination-bullet { cursor: pointer; padding-inline: 0.5rem; + outline: none; } .carousel-pagination-bullet[data-active] { @@ -71,3 +71,31 @@ .carousel-conditional .carousel-slide[data-active] { opacity: 1; } + +.carousel-stepper { + display: flex; + justify-content: space-between; +} + +.carousel-step { + display: flex; + gap: 1rem; + align-items: center; +} + +.carousel-step::before { + content: attr(data-step); + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background-color: hsl(var(--muted)); + + /* Slight adjustment for visual centering */ + padding-bottom: 0.1em; +} + +.carousel-step[data-current]::before { + background-color: hsl(var(--primary)); +} diff --git a/apps/website/src/routes/docs/headless/carousel/examples/progress.tsx b/apps/website/src/routes/docs/headless/carousel/examples/progress.tsx new file mode 100644 index 000000000..76abcbbb8 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/progress.tsx @@ -0,0 +1,38 @@ +import { component$, PropsOf, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Carousel, Progress } from '@qwik-ui/headless'; +import styles from '../../progress/snippets/progress.css?inline'; + +export const CarouselProgress = component$((props: PropsOf) => { + useStyles$(styles); + + return ( + + + + ); +}); + +export default component$(() => { + useStyles$(styles); + + const progress = useSignal(0); + const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; + + return ( + <> + + + + {colors.map((color) => ( + + {color} + + ))} + + + ); +}); +// internal diff --git a/apps/website/src/routes/docs/headless/carousel/examples/stepper-no-scroll.tsx b/apps/website/src/routes/docs/headless/carousel/examples/stepper-no-scroll.tsx new file mode 100644 index 000000000..72af429a4 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/stepper-no-scroll.tsx @@ -0,0 +1,22 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + {Array.from({ length: 3 }).map((_, index) => ( + Header {index + 1} + ))} + + + {Array.from({ length: 3 }).map((_, index) => ( + Content {index + 1} + ))} + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/stepper-presentational.tsx b/apps/website/src/routes/docs/headless/carousel/examples/stepper-presentational.tsx new file mode 100644 index 000000000..f27efeb7b --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/stepper-presentational.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + {Array.from({ length: 3 }).map((_, index) => ( + + Header {index + 1} + + ))} + + + + {Array.from({ length: 3 }).map((_, index) => ( + Content {index + 1} + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/stepper.tsx b/apps/website/src/routes/docs/headless/carousel/examples/stepper.tsx new file mode 100644 index 000000000..d89e46b5c --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/stepper.tsx @@ -0,0 +1,24 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + {Array.from({ length: 3 }).map((_, index) => ( + Header {index + 1} + ))} + + + + {Array.from({ length: 3 }).map((_, index) => ( + Content {index + 1} + ))} + + + ); +}); +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/examples/test-player-visible.tsx b/apps/website/src/routes/docs/headless/carousel/examples/test-player-visible.tsx new file mode 100644 index 000000000..7700a5c66 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/test-player-visible.tsx @@ -0,0 +1,46 @@ +import { component$, useOn, 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 isPlaying = useSignal(false); + + useOn( + 'qvisible', + $(() => { + isPlaying.value = true; + }), + ); + + 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/vertical-stepper.tsx b/apps/website/src/routes/docs/headless/carousel/examples/vertical-stepper.tsx new file mode 100644 index 000000000..87e174f6b --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/vertical-stepper.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const space = { marginBlock: '1rem' }; + + return ( + + {/* example stepper css uses flex for horizontal examples */} + + {Array.from({ length: 3 }).map((_, index) => ( + <> + Header {index + 1} + + Content {index + 1} + + + ))} + + + ); +}); +// 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 de43bc2cc..632ff9d46 100644 --- a/apps/website/src/routes/docs/headless/carousel/index.mdx +++ b/apps/website/src/routes/docs/headless/carousel/index.mdx @@ -27,20 +27,18 @@ Displays multiple content items in one space, rotating through them. 'Support for multiple slides per view', 'Reactive slide updates', 'Initial slide selection', + 'Carousels can be horizontal or vertical', 'Customizable accessible names', - 'Supports scroller and conditional carousels', - ]} - roadmap={[ - 'Improve test coverage', - 'Enhance documentation', - 'Refine API for consistency with other components', - 'Add support for vertical carousels', + 'Supports carousels with or without scrollers', + 'Optionally exposed as an accessible stepper', ]} /> -## CSS Scroll snapping +## Why use the Qwik UI Carousel? + +### CSS Scroll snapping -Qwik UI combines CSS scroll snapping and flexbox for the carousel: +On coarse devices and when getting initial slide positions, Qwik UI combines CSS scroll snapping and flexbox for an optimized swiping experience: - 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. @@ -88,27 +86,53 @@ Qwik UI combines CSS scroll snapping and flexbox for the carousel: } ``` -## Pagination +## Alignment Options -Use `` and `` components to add pagination. +### Start Alignment - +The default alignment is `start`. Slides will snap to the left edge of the carousel. -> These are exposed to assistive technologies as tabs for screen readers. + + +### Center Alignment + +Set the `align` prop to `center` to align slides to the center of the carousel. + + + +### End Alignment + +Set the `align` prop to `end` to align slides to the right edge of the carousel. + + + +## Component State + +### Initial + +To set an initial slide position, use the `startIndex` prop. + + -## Multiple Slides +### Reactive + +Reactively control the selected slide index by using the `bind:selectedIndex` prop. + + + +### Multiple Slides Set the `slidesPerView` prop for multiple slides. -## Non-draggable +### Non-draggable Opt-out of the draggable behavior by setting the `draggable` prop to `false`. -## Different widths +### Different widths By default, the slides will take up the full width of the carousel. @@ -116,7 +140,7 @@ To change this, use the `flex-basis` CSS property on the `` co -## Without Scroller +### No Scroll Qwik UI supports carousels without a scroller, which can be useful for conditional slide carousels. @@ -124,9 +148,7 @@ Qwik UI supports carousels without a scroller, which can be useful for condition Remove the `` component to remove the scroller. -## Animations - -### Conditional Slides +#### Example Conditional Animation @@ -150,35 +172,31 @@ Remove the `` component to remove the scroller. } ``` -## CSR +### CSR Both SSR and CSR are supported. In this example, we conditionally render the carousel based on an interaction. -## Center +### Loop -Align slides to the center of the carousel by setting the `align` prop to `center`. - - - -## End +Loop the carousel by setting the `loop` prop to `true`. -Align slides to the end of the carousel by setting the `align` prop to `end`. + - +> When looping, navigation buttons are never disabled. -## Loop +### Autoplay -Loop the carousel by setting the `loop` prop to `true`. +To use autoplay, use the `bind:autoplay` prop. - + -> When looping, navigation buttons are never disabled. +### Accessible name -## Accessible Name +By default, the carousel is automatically labeled with the `aria-label` attribute. -Add an accessible name to the carousel by adding the `` component. +In the case that you want to add a custom accessible name, use the `` component. @@ -186,13 +204,7 @@ 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? +#### 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. @@ -206,17 +218,53 @@ Use a visible task to change the signal passed to `bind:autoplay` to `true` when ``` -## Initial +## Configurations -To set an initial slide position, use the `startIndex` prop. +### Pagination - +Use `` and `` components to add pagination. + + -## Reactive +Inspired by [Adam Argyle's carousel examples](https://gui-challenges.web.app/carousel/dist/), the carousel component allows the pagination to be extendable, while intending to be intuitive. -Reactively control the selected slide index by using the `bind:selectedIndex` prop. +> These are exposed to assistive technologies as tabs for screen readers. - +### Stepper + +The Carousel component also includes built-in accessibility support for steppers and setup wizards. + + + +Steps can be disabled and enabled based on the index of the current slide or any other piece of state. + +> Steppers are seen by screen readers as a navigation area with series of steps that the user can navigate through. + +#### No Scroll + +Similar to Carousel's, steppers can be used without a scroller. + + + +#### Vertical + +Vertical steppers can be created by changing the markup position of the stepper. + + + +#### Presentational + +Create non-interactive steppers by setting the `as` prop to `div` or `span`. Use `Carousel.Next` and `Carousel.Previous` components for navigation instead. + + + +### Progress + +You can also control the progress of the carousel by using the `bind:progress` prop. + + + +In the above example, we also use the headless progress component to show the progress of the carousel. ## API diff --git a/apps/website/src/routes/docs/headless/progress/examples/complete.tsx b/apps/website/src/routes/docs/headless/progress/examples/complete.tsx new file mode 100644 index 000000000..18624c514 --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/complete.tsx @@ -0,0 +1,15 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + return ( + + + + ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/csr.tsx b/apps/website/src/routes/docs/headless/progress/examples/csr.tsx new file mode 100644 index 000000000..805c3e704 --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/csr.tsx @@ -0,0 +1,23 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const progress = 30; + const isRendered = useSignal(false); + + return ( + <> + + {isRendered.value && ( + + + + )} + + ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/hero.tsx b/apps/website/src/routes/docs/headless/progress/examples/hero.tsx index 26dd98e4a..452374e03 100644 --- a/apps/website/src/routes/docs/headless/progress/examples/hero.tsx +++ b/apps/website/src/routes/docs/headless/progress/examples/hero.tsx @@ -1,6 +1,5 @@ import { component$, useStyles$ } from '@builder.io/qwik'; import { Progress } from '@qwik-ui/headless'; -import styles from '../snippets/progress.css?inline'; export default component$(() => { useStyles$(styles); @@ -9,12 +8,10 @@ export default component$(() => { return ( - + ); }); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/indeterminate.tsx b/apps/website/src/routes/docs/headless/progress/examples/indeterminate.tsx new file mode 100644 index 000000000..d553522ff --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/indeterminate.tsx @@ -0,0 +1,17 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const progressSig = useSignal(null); + + return ( + + + + ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/max.tsx b/apps/website/src/routes/docs/headless/progress/examples/max.tsx new file mode 100644 index 000000000..1f2527e2f --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/max.tsx @@ -0,0 +1,48 @@ +import { component$, useSignal, useStyles$, $ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const initialNumTreats = 25; + + const totalTreats = useSignal(initialNumTreats); + const treatsEaten = 20; + + const space = { margin: '1rem' }; + + const increment = $(() => totalTreats.value++); + const decrement = $(() => { + if (totalTreats.value > 20) totalTreats.value--; + }); + + return ( + <> +

🧁 Tiara's Treats

+ +
+ Total treats: + + {totalTreats.value} + +
+ + + + + +

Number of eaten treats: {treatsEaten}

+ + ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/min.tsx b/apps/website/src/routes/docs/headless/progress/examples/min.tsx new file mode 100644 index 000000000..8a021e62f --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/min.tsx @@ -0,0 +1,53 @@ +import { component$, useSignal, useStyles$, $ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const fundraisingGoal = 10000; + const amountRaised = 5000; + const minGoal = useSignal(2000); + + const space = { margin: '1rem' }; + + const incrementMin = $(() => { + if (minGoal.value < amountRaised) minGoal.value += 500; + }); + + const decrementMin = $(() => { + if (minGoal.value > 0) minGoal.value -= 500; + }); + + return ( +
+

🎗️ Charity Fundraiser

+ +
+ Initial funding: + + ${minGoal.value} + +
+ +
Amount raised: ${amountRaised}
+ + + + + +

Funding goal: ${fundraisingGoal}

+
+ ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/examples/reactive.tsx b/apps/website/src/routes/docs/headless/progress/examples/reactive.tsx new file mode 100644 index 000000000..4861b0fe5 --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/examples/reactive.tsx @@ -0,0 +1,20 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Progress } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const progressSig = useSignal(30); + + return ( + <> + + + + + + ); +}); + +// internal +import styles from '../snippets/progress.css?inline'; diff --git a/apps/website/src/routes/docs/headless/progress/index.mdx b/apps/website/src/routes/docs/headless/progress/index.mdx index 557a00d3a..89a3180e5 100644 --- a/apps/website/src/routes/docs/headless/progress/index.mdx +++ b/apps/website/src/routes/docs/headless/progress/index.mdx @@ -3,7 +3,9 @@ title: Qwik UI | Progress --- import { statusByComponent } from '~/_state/component-statuses'; + import { FeatureList } from '~/components/feature-list/feature-list'; + import { Note } from '~/components/note/note'; @@ -16,7 +18,88 @@ A visual indicator that shows how much of a task has been completed. ## ✨ Features - + + +## Defining the range + +### Min + +The `min` prop sets a minimum starting point for the progress bar. + + + +### Adding a value + +To set a value for the progress bar, use the `value` prop in the `Progress.Root` component. + + + +### Max + +The `max` prop defines the upper limit of the progress bar. + + + +## Status + +### In progress + +When the task is ongoing, the progress component reflects this state. + + + +The `data-progress="in-progress"` attribute is applied to the progress and its indicator. + +### Complete + +When the task is finished, the `data-progress="complete"` attribute is applied. + + + +### Indeterminate progress + +If the progress is uncertain, the bar should be in an indeterminate state. This occurs when the value is `null`. + + + +In this state, the progress and its indicator will have the `data-progress="indeterminate"` attribute. + +> Indeterminate progress is ideal for indicating ongoing operations without a clear completion time. + +#### Indeterminate Example CSS + + + +## State + +### Initial Progress + +To set the initial progress on page load, you can pass a `value` prop to the `Progress.Root` component. + + + +### Dynamic Progress Updates + +To update the progress bar in real-time, pass a signal to the `bind:value` prop. + + + +The progress bar can also be used in combination with other Qwik UI headless components, such as the Carousel component when the user [navigates through the slides](../carousel/#progress). + +## CSR + +Similar to other Qwik UI headless components, the Progress Bar automatically renders based on its environment. The previous progress bars are rendered on the server. + + + +The example above shows the progress bar being rendered on the client when the `isRendered` signal is set to `true`. ## Building blocks @@ -49,8 +132,15 @@ A visual indicator that shows how much of a task has been completed. propDescriptors={[ { name: 'value', + type: 'union', + info: 'number | null', + description: + 'Current value of the progress bar. Can be null for indeterminate state.', + }, + { + name: 'min', type: 'number', - description: 'Current value of the progress bar.', + description: 'Minimum starting point for the progress bar.', }, { name: 'max', @@ -61,7 +151,14 @@ A visual indicator that shows how much of a task has been completed. name: 'getValueLabel', type: 'function', description: - 'A function to get the accessible label text representing the current value in a human-readable format. If not provided, the value label will be read as the numeric value as a percentage of the max value.', + 'A function to get the accessible label for the current value. If not provided, the label will be shown as a percentage of the range.', + }, + { + name: 'bind:value', + type: 'Signal', + info: 'Signal', + description: + 'A signal to bind the current value of the progress bar for reactive updates.', }, ]} /> diff --git a/apps/website/src/routes/docs/headless/progress/snippets/indeterminate.css b/apps/website/src/routes/docs/headless/progress/snippets/indeterminate.css new file mode 100644 index 000000000..bf84d1ae5 --- /dev/null +++ b/apps/website/src/routes/docs/headless/progress/snippets/indeterminate.css @@ -0,0 +1,15 @@ +@media (prefers-reduced-motion: no-preference) { + .progress-indicator.indeterminate { + width: 30%; + animation: indeterminate-slide 2s infinite cubic-bezier(0.37, 0, 0.63, 1); + } +} + +@keyframes indeterminate-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} diff --git a/apps/website/src/routes/docs/headless/progress/snippets/progress.css b/apps/website/src/routes/docs/headless/progress/snippets/progress.css index 4374333f4..2b77c2f00 100644 --- a/apps/website/src/routes/docs/headless/progress/snippets/progress.css +++ b/apps/website/src/routes/docs/headless/progress/snippets/progress.css @@ -11,3 +11,40 @@ width: 100%; background-color: hsl(var(--primary)); } + +@keyframes loading-flash { + 0% { + opacity: 0.1; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.1; + } +} + +@media (prefers-reduced-motion: no-preference) { + .progress-indicator.indeterminate { + width: 30%; + animation: indeterminate-slide 2s infinite cubic-bezier(0.37, 0, 0.63, 1); + } +} + +@keyframes indeterminate-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} + +.progress-example-input { + border-radius: calc(var(--border-radius) / 2); + border: 2px dotted hsl(var(--primary)); + margin-left: 0.5rem; + max-width: 3rem; + padding: 0.25rem; + outline: none; +} diff --git a/apps/website/src/routes/docs/styled/progress/examples/hero.tsx b/apps/website/src/routes/docs/styled/progress/examples/hero.tsx index 54c3b76e7..58570940e 100644 --- a/apps/website/src/routes/docs/styled/progress/examples/hero.tsx +++ b/apps/website/src/routes/docs/styled/progress/examples/hero.tsx @@ -2,7 +2,7 @@ import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'; import { Progress } from '~/components/ui'; export default component$(() => { - const progress = useSignal(30); + const progress = useSignal(20); useVisibleTask$(() => { setTimeout(() => { @@ -10,5 +10,5 @@ export default component$(() => { }, 500); }); - return ; + return ; }); diff --git a/packages/kit-headless/src/components/carousel/actionable.md b/packages/kit-headless/src/components/carousel/actionable.md deleted file mode 100644 index 91a991e0b..000000000 --- a/packages/kit-headless/src/components/carousel/actionable.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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/carousel.test.ts b/packages/kit-headless/src/components/carousel/carousel.test.ts index a8434d916..c43c83881 100644 --- a/packages/kit-headless/src/components/carousel/carousel.test.ts +++ b/packages/kit-headless/src/components/carousel/carousel.test.ts @@ -15,252 +15,461 @@ async function setup(page: Page, exampleName: string) { 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 - */ + THEN it should move to the next slide`, async ({ page }) => { 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(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); }); test(`GIVEN a carousel WHEN clicking on the previous button - THEN it should move to the previous slide - `, async ({ page }) => { + 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) + // initial setup await d.getNextButton().click(); - await expect(d.getSlideAt(1)).toBeVisible(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); - // test previous work + // testing clicking the "previous" button + await d.getPrevButton().click(); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); 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 }) => { + 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; + // Ensure the first slide is active + const firstSlide = d.getSlideAt(0); + await expect(firstSlide).toHaveAttribute('data-active'); + + // grab first slide dimensions + const boundingBox = await d.getSlideBoundingBoxAt(0); + + const startX = boundingBox.x + boundingBox.width * 0.8; // near right edge + const endX = boundingBox.x + boundingBox.width * 0.2; // near left edge + const y = boundingBox.y + boundingBox.height / 2; // swipe height + + // perform the drag action + await firstSlide.hover({ position: { x: 5, y: 5 } }); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 10 }); + await page.mouse.up(); - // TODO + // second slide should be active + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); }); 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 }) => { + 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; + // initial setup + await d.getNextButton().click(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + const boundingBox = await d.getSlideBoundingBoxAt(1); - // TODO + const endX = boundingBox.x + boundingBox.width * 0.9; // End closer to the right edge + const y = 5; + + // dragging + const slide = d.getSlideAt(1); + await slide.hover({ position: { x: 5, y: 5 } }); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 5 }); + await page.mouse.up(); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); test(`GIVEN a carousel with a pagination control WHEN clicking on the pagination bullets - THEN it should move to the corresponding slide -`, async ({ page }) => { + 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; + await d.getPaginationBullet(6).click(); + await expect(d.getSlideAt(6)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(2)).not.toHaveAttribute('data-active'); - // TODO + await d.getPaginationBullet(1).click(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(6)).not.toHaveAttribute('data-active'); }); 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 }) => { + WHEN dragging the carousel + THEN it should affect the slide position`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + const initialSlideBox = await d.getSlideBoundingBoxAt(0); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + + const startX = initialSlideBox.x + initialSlideBox.width * 0.8; + const endX = initialSlideBox.x + initialSlideBox.width * 0.2; + const y = initialSlideBox.y + initialSlideBox.height / 2; + + await d.getSlideAt(0).hover({ position: { x: 5, y: 5 } }); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 10 }); + await page.mouse.up(); + + const finalSlideBox = await d.getSlideBoundingBoxAt(0); + expect(Math.abs(finalSlideBox.x - initialSlideBox.x)).toBeGreaterThan(1); + }); - // TODO + test(`GIVEN a carousel with dragging disabled + WHEN attempting to drag the carousel + THEN it should not affect the slide position`, async ({ page }) => { + const { driver: d } = await setup(page, 'non-draggable'); + + const initialSlideBox = await d.getSlideBoundingBoxAt(0); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + + const startX = initialSlideBox.x + initialSlideBox.width * 0.8; + const endX = initialSlideBox.x + initialSlideBox.width * 0.2; + const y = initialSlideBox.y + initialSlideBox.height / 2; + + await d.getSlideAt(0).hover({ position: { x: 5, y: 5 } }); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 10 }); + await page.mouse.up(); + + const finalSlideBox = await d.getSlideBoundingBoxAt(0); + expect(finalSlideBox.x).toBeCloseTo(initialSlideBox.x, 0); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); 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 }) => { + WHEN dragging to the next slide + THEN the next slide should snap to the left side of the scroller`, async ({ + page, + }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + const initialSlideBox = await d.getSlideBoundingBoxAt(0); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + + const startX = initialSlideBox.x + initialSlideBox.width * 0.8; + const endX = initialSlideBox.x + initialSlideBox.width * 0.2; + const y = initialSlideBox.y + initialSlideBox.height / 2; - // TODO + await d.getSlideAt(0).hover({ position: { x: 5, y: 5 } }); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 10 }); + await page.mouse.up(); + await d.getSlideAt(1).hover({ position: { x: 5, y: 5 } }); + + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + const scrollerBox = await d.getScrollerBoundingBox(); + const secondSlideBox = await d.getSlideBoundingBoxAt(1); + + // we need to check the space between the scroller left and the second slide left + expect(Math.abs(secondSlideBox.x - scrollerBox.x)).toBeLessThan(1); + }); + + test(`GIVEN a carousel + WHEN clicking the next button + THEN the next slide should snap to the left side of the scroller`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'hero'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getNextButton().click(); + + const scrollerBox = await d.getScrollerBoundingBox(); + const secondSlideBox = await d.getSlideBoundingBoxAt(1); + + const gap = await page.evaluate(() => { + const scroller = document.querySelector('[data-qui-carousel-scroller]'); + return parseInt(window.getComputedStyle(scroller!).getPropertyValue('gap')); + }); + + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + const slideWidth = secondSlideBox.width; + + // we need to check the distance travelled to the next slide + await expect(Math.abs(secondSlideBox.x - scrollerBox.x) - gap).toBeCloseTo( + slideWidth, + 0, + ); }); }); 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 }) => { + THEN it should move to the next slide`, async ({ page }) => { const { driver: d } = await setup(page, 'pagination'); + await d.getNextButton().focus(); await d.getNextButton().press('Enter'); - await expect(d.getSlideAt(1)).toBeVisible(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + await d.getNextButton().focus(); + await d.getNextButton().press('Enter'); + + await expect(d.getSlideAt(2)).toHaveAttribute('data-active'); }); 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 }) => { + 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; + await expect(d.getPrevButton()).toBeDisabled(); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + + await d.getPaginationBullet(6).click(); + await expect(d.getSlideAt(6)).toHaveAttribute('data-active'); - // TODO + await d.getPrevButton().press('Enter'); + await expect(d.getSlideAt(5)).toHaveAttribute('data-active'); }); 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 }) => { + 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; + // Focus the first pagination bullet + const firstBullet = await d.getPaginationBullet(0); + await d.getPaginationBullet(0).focus(); - // TODO + await expect(firstBullet).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('ArrowRight'); + + await expect(d.getPaginationBullet(1)).toHaveAttribute('aria-selected', 'true'); }); 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 }) => { + THEN focus should move to the previous bullet`, async ({ page }) => { const { driver: d } = await setup(page, 'pagination'); + const secondBullet = d.getPaginationBullet(1); + await secondBullet.focus(); - // remove this (there so that TS doesn't complain) - d; + await expect(secondBullet).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('ArrowLeft'); - // TODO + await expect(d.getPaginationBullet(0)).toHaveAttribute('aria-selected', 'true'); }); 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 }) => { + 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; + // Focus on the first pagination bullet + const firstBullet = d.getPaginationBullet(0); + await firstBullet.focus(); - // TODO + await page.keyboard.press('ArrowRight'); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); }); 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 }) => { + THEN focus 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; + // initial + const secondBullet = d.getPaginationBullet(1); + await secondBullet.focus(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); - // TODO + await page.keyboard.press('ArrowLeft'); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); 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 }) => { + 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; + await d.getPaginationBullet(0).focus(); - // TODO + await page.keyboard.press('End'); + const totalSlides = await d.getTotalSlides(); + await expect(d.getSlideAt(totalSlides - 1)).toHaveAttribute('data-active'); }); 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 }) => { + 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'); + const totalSlides = await d.getTotalSlides(); + const lastBullet = d.getPaginationBullet(totalSlides - 1); + await lastBullet.focus(); + await expect(lastBullet).toHaveAttribute('aria-selected', 'true'); - // remove this (there so that TS doesn't complain) - d; - - // TODO + await page.keyboard.press('Home'); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); }); +// PW doesn't support swipe actions, which is why we use mouse events in-between taps test.describe('Mobile / Touch Behavior', () => { + test.use({ viewport: { width: 414, height: 896 }, isMobile: true, hasTouch: true }); + + test(`GIVEN a mobile carousel + WHEN it is rendered + THEN it should use CSS scroll snap for swiping`, async ({ page }) => { + await setup(page, 'hero'); + + // is css scroll snapping enabled + const isCoarsePointer = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + expect(isCoarsePointer).toBe(true); + }); + test(`GIVEN a carousel with dragging enabled WHEN swiping to the left - THEN it should move to the next slide -`, async ({ page }) => { + 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; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + const boundingBox = await d.getSlideBoundingBoxAt(0); - // TODO + const startX = boundingBox.x + boundingBox.width * 0.8; // near right edge + const endX = boundingBox.x + boundingBox.width * 0.2; // near left edge + const y = boundingBox.y + boundingBox.height / 2; // swipe height + + // perform the swipe action + await page.touchscreen.tap(startX, y); + await page.mouse.move(startX, y, { steps: 10 }); + await page.mouse.move(endX, y, { steps: 10 }); + await page.touchscreen.tap(endX, y); + + // second slide should be active + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + await expect(d.getPrevButton()).toBeEnabled(); }); test(`GIVEN a carousel with dragging enabled WHEN swiping to the right - THEN it should move to the previous slide -`, async ({ page }) => { + 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; + // initial setup + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getNextButton().tap(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + const boundingBox = await d.getSlideBoundingBoxAt(0); + + const startX = boundingBox.x + boundingBox.width * 0.2; // near left edge + const endX = boundingBox.x + boundingBox.width * 0.8; // near right edge + const y = boundingBox.y + boundingBox.height / 2; // swipe height + + await page.touchscreen.tap(startX, y); + await page.mouse.move(startX, y, { steps: 10 }); + await page.mouse.move(endX, y, { steps: 10 }); + await page.touchscreen.tap(endX, y); - // TODO + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await expect(d.getPrevButton()).toBeDisabled(); + }); + + test(`GIVEN a carousel with dragging enabled + WHEN tapping on the next button + THEN it should move to the next slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getNextButton().tap(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + }); + + test(`GIVEN a carousel with dragging enabled + WHEN tapping on the previous button + THEN it should move to the previous slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getNextButton().tap(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + await d.getPrevButton().tap(); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); }); test(`GIVEN a carousel with a pagination control WHEN tapping on the pagination bullets - THEN it should move to the corresponding slide -`, async ({ page }) => { + 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; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); - // TODO + await d.getPaginationBullet(3).tap(); + await expect(d.getSlideAt(3)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(0)).not.toHaveAttribute('data-active'); }); - 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 }) => { + test(`GIVEN a mobile carousel + WHEN swiping to the next slide + THEN the next slide should snap to the left side of the scroller`, async ({ + page, + }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + const boundingBox = await d.getSlideBoundingBoxAt(0); + + const startX = boundingBox.x + boundingBox.width * 0.8; + const endX = boundingBox.x + boundingBox.width * 0.2; + const y = boundingBox.y + boundingBox.height / 2; + + await page.touchscreen.tap(startX, y); + await page.mouse.move(startX, y, { steps: 10 }); + await page.mouse.move(endX, y, { steps: 10 }); + await page.touchscreen.tap(endX, y); - // TODO + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + const scrollerBox = await d.getScrollerBoundingBox(); + const secondSlideBox = await d.getSlideBoundingBoxAt(1); + + expect(Math.abs(secondSlideBox.x - scrollerBox.x)).toBeLessThan(1); // Allow 1px tolerance }); - 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 }) => { + test(`GIVEN a mobile carousel + WHEN tapping the next button + THEN the next slide should snap to the left side of the scroller`, async ({ + page, + }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getNextButton().tap(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + const scrollerBox = await d.getScrollerBoundingBox(); + const secondSlideBox = await d.getSlideBoundingBoxAt(1); + + const gap = await page.evaluate(() => { + const scroller = document.querySelector('[data-qui-carousel-scroller]'); + return parseInt(window.getComputedStyle(scroller!).getPropertyValue('gap')); + }); + + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + const slideWidth = secondSlideBox.width; + + const actualDifference = Math.abs(secondSlideBox.x - scrollerBox.x); - // TODO + // within range (e.g., ±20 pixels) + expect(Math.abs(actualDifference - slideWidth)).toBeLessThanOrEqual(20); }); }); @@ -277,8 +486,7 @@ test.describe('Accessibility', () => { test(`GIVEN a carousel WHEN it is rendered - THEN it should have an accessible name -`, async ({ page }) => { + 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) @@ -290,227 +498,419 @@ test.describe('Accessibility', () => { 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 }) => { + THEN 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'); + await expect(d.getRoot()).toHaveAttribute('aria-labelledby'); }); test(`GIVEN a carousel WHEN it is rendered - THEN the carousel container should have the role of group -`, async ({ page }) => { + 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 + await expect(d.getRoot()).toBeVisible(); + await expect(d.getRoot()).toHaveRole('group'); }); test(`GIVEN a carousel WHEN it is rendered - THEN the items should have a posinset of its current index -`, async ({ page }) => { + THEN the slide should have the accessible as its index out of the total slides`, async ({ + page, + }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + await expect(d.getRoot()).toBeVisible(); - // TODO + await expect(d.getSlideAt(1)).toHaveAccessibleName('2 of 7'); + await expect(d.getSlideAt(2)).toHaveAccessibleName('3 of 7'); }); 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 }) => { + 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 + // Verify the parent element has the role of 'tablist' + await expect(d.getSlideTabsParent()).toHaveRole('tablist'); }); test(`GIVEN a carousel with a pagination control WHEN it is rendered - THEN each bullet should have the role of tab -`, async ({ page }) => { + 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; + const bulletCount = await d.getTotalSlides(); - // TODO + // Verify each bullet has the role of 'tab' + for (let i = 0; i < bulletCount; i++) { + const bullet = await d.getPaginationBullet(i); + await expect(bullet).toHaveRole('tab'); + } }); - // 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 }) => { + THEN it should be inert`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(1)).toHaveAttribute('inert'); - // remove this (there so that TS doesn't complain) - d; + await d.getNextButton().click(); - // TODO + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(0)).toHaveAttribute('inert'); }); - test(`GIVEN a carousel - WHEN on the current slide - THEN items inside the slide should be the only focusable items -`, async ({ page }) => { + test(`GIVEN a carousel stepper + WHEN it is rendered + THEN the current slide's step should have aria-current="step"`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'stepper'); + await expect(d.getStepAt(0)).toHaveAttribute('aria-current', 'step'); + }); + + test(`GIVEN a carousel stepper + WHEN it is rendered + THEN the stepper parent should have role="navigation"`, async ({ page }) => { + const { driver: d } = await setup(page, 'stepper'); + await expect(d.getStepperParent()).toHaveRole('navigation'); + await expect(d.getStepperParent()).toBeVisible(); + }); +}); + +test.describe('Looping', () => { + test(`GIVEN a carousel with loop disabled + WHEN navigating via keyboard to the last slide + THEN the previous button should be focused after 2 seconds`, async ({ page }) => { const { driver: d } = await setup(page, 'hero'); - // remove this (there so that TS doesn't complain) - d; + const totalSlides = await d.getTotalSlides(); + + for (let i = 0; i < totalSlides - 1; i++) { + await d.getNextButton().press('Enter'); + } + + await expect(d.getSlideAt(totalSlides - 1)).toHaveAttribute('data-active'); + + // if focus doesn't change in 2 seconds in next impl, then it focuses prev + await page.waitForTimeout(2000); + await expect(d.getPrevButton()).toBeFocused(); + }); + + test(`GIVEN a carousel with loop disabled + WHEN navigating via keyboard to the first slide + THEN the next button should be focused after 2 seconds`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + await d.getNextButton().press('Enter'); + await d.getPrevButton().press('Enter'); - // TODO + await page.waitForTimeout(2000); + await expect(d.getNextButton()).toBeFocused(); }); test(`GIVEN a carousel with loop disabled WHEN on the last slide - THEN the previous button should be focused -`, async ({ page }) => { + 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; + const totalSlides = await d.getTotalSlides(); - // TODO + for (let i = 0; i < totalSlides - 1; i++) { + await d.getNextButton().click(); + } + + await expect(d.getNextButton()).toBeDisabled(); }); test(`GIVEN a carousel with loop disabled WHEN on the first slide - THEN the next button should be focused -`, async ({ page }) => { + 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; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await expect(d.getPrevButton()).toHaveAttribute('disabled'); // + }); + + 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 }) => { + const { driver: d } = await setup(page, 'loop'); + + const totalSlides = await d.getTotalSlides(); + for (let i = 0; i < totalSlides - 1; i++) { + await d.getNextButton().click(); + } + + const lastSlide = d.getSlideAt(totalSlides - 1); + + await expect(lastSlide).toHaveAttribute('data-active'); + + await expect(d.getNextButton()).toBeVisible(); + await expect(d.getNextButton()).toBeEnabled(); + await d.getNextButton().click(); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + }); - // 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 last slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getPrevButton().click(); + + const totalSlides = await d.getTotalSlides(); + await expect(d.getSlideAt(totalSlides - 1)).toHaveAttribute('data-active'); }); }); -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'); +test.describe('Multiple slides', () => { + test(`GIVEN a carousel with multiple slides per view set to 3 + WHEN the carousel is rendered + THEN 3 slides should be visible at a time`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple-slides'); + await expect(d.getRoot()).toBeVisible(); + await expect(d.getSlideAt(0)).toBeVisible(); + await expect(d.getSlideAt(1)).toBeVisible(); + await expect(d.getSlideAt(2)).toBeVisible(); + }); - // remove this (there so that TS doesn't complain) - d; + test(`GIVEN a carousel with multiple slides per view set to 3 + WHEN the carousel is rendered + THEN the first 3 slides should be interactive`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple-slides'); + await expect(d.getRoot()).toBeVisible(); + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(2)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(3)).not.toHaveAttribute('data-active'); + }); - // TODO - }); + test(`GIVEN a carousel with multiple slides per view set to 3 + WHEN the next button is clicked + THEN the slides 2, 3, 4 should be interactive`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple-slides'); + await expect(d.getRoot()).toBeVisible(); + await d.getNextButton().click(); + await expect(d.getSlideAt(0)).not.toHaveAttribute('data-active'); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(2)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(3)).toHaveAttribute('data-active'); + }); - 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'); + test(`GIVEN a carousel with multiple slides per view set to 3 + WHEN the carousel is rendered + THEN slide 4 should not be interactive`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple-slides'); + await expect(d.getRoot()).toBeVisible(); + await expect(d.getSlideAt(0)).not.toHaveAttribute('inert'); + await expect(d.getSlideAt(3)).toHaveAttribute('inert'); + }); +}); - // remove this (there so that TS doesn't complain) - d; +test.describe('CSR', () => { + test(`GIVEN a button that conditionally renders a carousel + WHEN the button is clicked + THEN the carousel should be visible`, async ({ page }) => { + const { driver: d } = await setup(page, 'csr'); + await expect(d.getRoot()).not.toBeVisible(); + await expect(d.getNextButton()).not.toBeVisible(); + await expect(d.getPrevButton()).not.toBeVisible(); + await expect(d.getSlideAt(0)).not.toBeVisible(); - // TODO - }); + await page.getByRole('button').click(); - 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'); + await expect(d.getRoot()).toBeVisible(); + await expect(d.getNextButton()).toBeVisible(); + await expect(d.getPrevButton()).toBeVisible(); + await expect(d.getSlideAt(0)).toBeVisible(); + }); - // remove this (there so that TS doesn't complain) - d; + test(`GIVEN a button that conditionally renders a carousel + WHEN the button is clicked + THEN the carousel should be interactive`, async ({ page }) => { + const { driver: d } = await setup(page, 'csr'); + await expect(d.getRoot()).not.toBeVisible(); - // TODO - }); + await page.getByRole('button').click(); + + await expect(d.getNextButton()).toBeVisible(); + await expect(d.getSlideAt(1)).toBeVisible(); + }); - 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'); + test(`GIVEN a button that conditionally renders a carousel + WHEN the button is clicked and the carousel is dragged to the left + THEN it should move to the next slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'csr'); - // remove this (there so that TS doesn't complain) - d; + await page.getByRole('button').click(); - // TODO - }); + await expect(d.getNextButton()).toBeVisible(); + await expect(d.getSlideAt(1)).toBeVisible(); + + // Ensure the first slide is active + const firstSlide = d.getSlideAt(0); + await expect(firstSlide).toHaveAttribute('data-active'); + + // grab first slide dimensions + const boundingBox = await d.getSlideBoundingBoxAt(0); + + const startX = boundingBox.x + boundingBox.width * 0.8; // near right edge + const endX = boundingBox.x + boundingBox.width * 0.2; // near left edge + const y = boundingBox.y + boundingBox.height / 2; // swipe height + + // perform the drag action + await firstSlide.hover({ position: { x: 5, y: 5 } }); + await page.mouse.move(startX, y); + await page.mouse.down(); + await page.mouse.move(endX, y, { steps: 10 }); + await page.mouse.up(); + + // second slide should be active + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); }); +}); - 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 }) => { +test.describe('Autoplay', () => { + test(`GIVEN a carousel + WHEN the autoplay trigger is clicked + THEN it should automatically move to the next slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'player'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await d.getPlayer().click(); + + await page.waitForTimeout(3500); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + await page.waitForTimeout(3500); + await expect(d.getSlideAt(2)).toHaveAttribute('data-active'); + }); + + test(`GIVEN a carousel that has autoplay enabled when visible + WHEN the component is visible + THEN it should automatically move to the next slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-player-visible'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + await expect(d.getRoot()).toBeVisible(); + + await page.waitForTimeout(3500); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + + await page.waitForTimeout(3500); + await expect(d.getSlideAt(2)).toHaveAttribute('data-active'); + }); +}); + +test.describe('State', () => { + test(`GIVEN a carousel that has a start Index + WHEN the component is rendered + THEN that start index should be active + AND visible`, async ({ page }) => { const { driver: d } = await setup(page, 'initial'); - // remove this (there so that TS doesn't complain) - d; + await expect(d.getSlideAt(4)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(4)).toBeVisible(); + }); + + test(`GIVEN a carousel that can be reactively changed + WHEN the index is changed to 4 + THEN it should scroll to the fourth slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'reactive'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + + await page.locator('button', { hasText: 'Change to index 4' }).click(); - // TODO + await expect(d.getSlideAt(4)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(4)).toBeVisible(); }); - 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'); + test(`GIVEN a carousel that can be reactively changed + WHEN the index is changed to 4, to a new slide, and back + THEN it should update back to the fourth slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'reactive'); - // remove this (there so that TS doesn't complain) - d; + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); - // TODO - }); + await page.locator('button', { hasText: 'Change to index 4' }).click(); - 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'); + await expect(d.getSlideAt(4)).toHaveAttribute('data-active'); + await expect(d.getSlideAt(4)).toBeVisible(); - // remove this (there so that TS doesn't complain) - d; + await d.getNextButton().click(); + await expect(d.getSlideAt(5)).toHaveAttribute('data-active'); - // TODO - }); + await page.locator('button', { hasText: 'Change to index 4' }).click(); + + await expect(d.getSlideAt(4)).toHaveAttribute('data-active'); + }); + + test(`GIVEN a carousel with a progress bar + WHEN the next button is clicked + THEN it should affect the progress bar`, async ({ page }) => { + const { driver: d } = await setup(page, 'progress'); + const progressBar = page.getByRole('progressbar'); + await expect(progressBar).toBeVisible(); + await expect(progressBar).toHaveAttribute('aria-valuetext', '0%'); + + await d.getNextButton().click(); + + await expect(progressBar).toHaveAttribute('aria-valuetext', '17%'); }); }); -// 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) - * - */ +test.describe('Stepper', () => { + test(`GIVEN a carousel stepper + WHEN it is rendered + THEN the first step should be the current one`, async ({ page }) => { + const { driver: d } = await setup(page, 'stepper'); + await expect(d.getStepAt(0)).toHaveAttribute('data-current'); + await expect(d.getStepAt(1)).not.toHaveAttribute('data-current'); + }); + + test(`GIVEN a carousel stepper + WHEN clicking on the second step + THEN the second step should be the current one`, async ({ page }) => { + const { driver: d } = await setup(page, 'stepper'); + await expect(d.getStepAt(0)).toHaveAttribute('data-current'); + + await d.getStepAt(1).click(); + await expect(d.getStepAt(1)).toHaveAttribute('data-current'); + }); + + test(`GIVEN a carousel stepper + WHEN clicking on the second step + THEN it should move to the second slide`, async ({ page }) => { + const { driver: d } = await setup(page, 'stepper'); + await expect(d.getStepAt(0)).toHaveAttribute('data-current'); + + await d.getStepAt(1).click(); + await expect(d.getSlideAt(1)).toHaveAttribute('data-active'); + }); + + test(`GIVEN a carousel stepper that isn't interactive + WHEN the as prop is a div + THEN it should be a div and not be interactive`, async ({ page }) => { + const { driver: d } = await setup(page, 'stepper-presentational'); + + const firstStep = d.getStepAt(0); + let tagName; + + const isDiv = await firstStep.evaluate((el) => { + el.tagName === 'DIV'; + tagName = el.tagName; + }); + + expect(isDiv).toBe(tagName); + await expect(firstStep).not.toHaveAttribute('role', 'button'); + await expect(firstStep).not.toHaveAttribute('tabindex', '0'); + }); +}); diff --git a/packages/kit-headless/src/components/carousel/context.ts b/packages/kit-headless/src/components/carousel/context.ts index e6b1d8589..cb47ad33d 100644 --- a/packages/kit-headless/src/components/carousel/context.ts +++ b/packages/kit-headless/src/components/carousel/context.ts @@ -1,10 +1,8 @@ -import { createContextId } from '@builder.io/qwik'; +import { createContextId, type Signal } from '@builder.io/qwik'; -export const carouselContextId = createContextId('carousel-context'); +export const carouselContextId = createContextId('qui-carousel-context'); -import { type Signal } from '@builder.io/qwik'; - -export interface CarouselContext { +export type CarouselContext = { // core state localId: string; scrollerRef: Signal; @@ -26,5 +24,6 @@ export interface CarouselContext { alignSig: Signal<'start' | 'center' | 'end'>; isLoopSig: Signal; autoPlayIntervalMsSig: Signal; - initialIndex: number | undefined; -} + startIndex: number | undefined; + isStepInteractionSig: Signal; +}; diff --git a/packages/kit-headless/src/components/carousel/driver.ts b/packages/kit-headless/src/components/carousel/driver.ts index 182557f07..8d572a7a9 100644 --- a/packages/kit-headless/src/components/carousel/driver.ts +++ b/packages/kit-headless/src/components/carousel/driver.ts @@ -10,29 +10,70 @@ export function createTestDriver(rootLocator: T) { return getRoot().locator('[data-qui-carousel-next]'); }; + const getPaginationBullet = (index: number) => { + return getRoot().locator(`[tabindex]`).nth(index); + }; + + const getTotalSlides = () => { + return getRoot().locator(`[data-qui-carousel-slide]`).count(); + }; + + const getSlideTitleId = () => { + return getRoot().locator('[id]'); + }; + const getPrevButton = () => { return getRoot().locator('[data-qui-carousel-prev]'); }; - const getContainer = () => { + const getSlideTabsParent = () => { + // return getRoot().locator(`:nth-child(${0 + 1})`).nth(1);; + return getRoot().getByRole('tablist'); + }; + + const getScroller = () => { return getRoot().locator('[data-qui-carousel-scroller]'); }; + const getItems = () => { + return getRoot().locator(`[role]`); + }; + const getSlideAt = (index: number) => { - return getContainer().locator(`[data-qui-carousel-slide]`).nth(index); + return getScroller().locator(`[data-qui-carousel-slide]`).nth(index); + }; + + const getStepAt = (index: number) => { + return getRoot().locator(`[data-qui-carousel-step]`).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)), - ); - } + const getStepperParent = () => { + return getRoot().getByRole('navigation'); + }; + + const getSlideBoundingBoxAt = async (index: number) => { + const boundingBox = await getSlideAt(index).boundingBox(); + + if (!boundingBox) { + throw new Error('Could not determine the bounding box of the slide'); + } + + return boundingBox; + }; + + const getScrollerBoundingBox = async () => { + const boundingBox = await getScroller().boundingBox(); + + if (!boundingBox) { + throw new Error('Could not determine the bounding box of the scroller'); + } + + return boundingBox; + }; + + const getPlayer = () => { + return getRoot().locator('[data-qui-carousel-player]'); + }; return { ...rootLocator, @@ -40,8 +81,17 @@ export function createTestDriver(rootLocator: T) { getRoot, getNextButton, getPrevButton, - getContainer, + getScroller, getSlideAt, - waitForAnimationEnd, + getSlideBoundingBoxAt, + getPaginationBullet, + getTotalSlides, + getSlideTitleId, + getSlideTabsParent, + getItems, + getScrollerBoundingBox, + getPlayer, + getStepAt, + getStepperParent, }; } diff --git a/packages/kit-headless/src/components/carousel/index.ts b/packages/kit-headless/src/components/carousel/index.ts index e2b6ea364..15866aae9 100644 --- a/packages/kit-headless/src/components/carousel/index.ts +++ b/packages/kit-headless/src/components/carousel/index.ts @@ -7,3 +7,5 @@ export { CarouselPagination as Pagination } from './pagination'; export { CarouselBullet as Bullet } from './bullet'; export { CarouselTitle as Title } from './title'; export { CarouselPlayer as Player } from './player'; +export { CarouselStepper as Stepper } from './stepper'; +export { CarouselStep as Step } from './step'; diff --git a/packages/kit-headless/src/components/carousel/inline.tsx b/packages/kit-headless/src/components/carousel/inline.tsx index 3e9dd9959..80a804380 100644 --- a/packages/kit-headless/src/components/carousel/inline.tsx +++ b/packages/kit-headless/src/components/carousel/inline.tsx @@ -16,6 +16,7 @@ type InternalProps = { slideComponent?: typeof Carousel.Slide; bulletComponent?: typeof Carousel.Bullet; + stepComponent?: typeof Carousel.Step; titleComponent?: typeof Carousel.Title; }; @@ -28,14 +29,17 @@ export const CarouselRoot: Component = ( carouselBulletComponent: GivenBulletOld, slideComponent: GivenSlide, bulletComponent: GivenBullet, + stepComponent: GivenStep, titleComponent: GivenTitle, ...rest } = props; const Slide = GivenSlide || GivenSlideOld || Carousel.Slide; const Bullet = GivenBullet || GivenBulletOld || Carousel.Bullet; + const Step = GivenStep || Carousel.Step; const Title = GivenTitle || Carousel.Title; let currSlideIndex = 0; let currBulletIndex = 0; + let currStepIndex = 0; let numSlides = 0; let isTitle = false; @@ -53,6 +57,12 @@ export const CarouselRoot: Component = ( currBulletIndex++; }); + findComponent(Step, (stepProps) => { + stepProps._index = currStepIndex; + + currStepIndex++; + }); + findComponent(Title, () => { isTitle = true; }); diff --git a/packages/kit-headless/src/components/carousel/next.tsx b/packages/kit-headless/src/components/carousel/next.tsx index 87b39d6e1..f75df2bc5 100644 --- a/packages/kit-headless/src/components/carousel/next.tsx +++ b/packages/kit-headless/src/components/carousel/next.tsx @@ -68,7 +68,7 @@ export const CarouselNext = component$((props: PropsOf<'button'>) => { + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} + + ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx new file mode 100644 index 000000000..3f0b18740 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-multiple.tsx @@ -0,0 +1,32 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + + return ( +
+ + {isItemsRenderedSig.value && ( + + {items.map((item, index) => ( + + {item} + + ))} + + )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx new file mode 100644 index 000000000..c70b59f3b --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind-center.tsx @@ -0,0 +1,42 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + const valueSelected = useSignal('center'); + + return ( +
+ + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx new file mode 100644 index 000000000..8aa21aabf --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single-bind.tsx @@ -0,0 +1,42 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + const valueSelected = useSignal('left'); + + return ( +
+ + {isItemsRenderedSig.value && ( +
+ + {items.map((item, index) => ( + + {item} + + ))} + + You selected: {valueSelected.value} + +
+ )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx new file mode 100644 index 000000000..9b54bcf95 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-csr-order-single.tsx @@ -0,0 +1,32 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; +import { useSignal } from '@builder.io/qwik'; + +export default component$(() => { + useStyles$(styles); + const items = ['left', 'center', 'right']; + const isItemsRenderedSig = useSignal(false); + + return ( +
+ + {isItemsRenderedSig.value && ( + + {items.map((item, index) => ( + + {item} + + ))} + + )} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx new file mode 100644 index 000000000..d4c77df79 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx new file mode 100644 index 000000000..1dfad8a4c --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value-multiple.tsx @@ -0,0 +1,31 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + useStyles$(styles); + + return ( +
+ (valueSelected.value = v)} + disabled + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value.join(',')} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx new file mode 100644 index 000000000..99dd91206 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-disabled-value.tsx @@ -0,0 +1,33 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ { + valueSelected.value = v; + }} + disabled + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx new file mode 100644 index 000000000..0eae5dbac --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple-center-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx new file mode 100644 index 000000000..50d9602bc --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-multiple.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx new file mode 100644 index 000000000..e7931b542 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single-center-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx new file mode 100644 index 000000000..2817070a7 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-focus-single.tsx @@ -0,0 +1,26 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+
OutsideRoot Top
+
+ + + Left + + + Center + + + Right + + +
+ +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx new file mode 100644 index 000000000..bec622d8d --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-initialValue-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx new file mode 100644 index 000000000..8ff0a990f --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled-multiple.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx new file mode 100644 index 000000000..4ecd8017f --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-item-disabled.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx new file mode 100644 index 000000000..661db3c40 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-horizontal-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx new file mode 100644 index 000000000..9dac46021 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx new file mode 100644 index 000000000..a851747de --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-loop-vertical.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx new file mode 100644 index 000000000..efb794c50 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind-multiple.tsx @@ -0,0 +1,27 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx new file mode 100644 index 000000000..8c87fdb23 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-bind.tsx @@ -0,0 +1,27 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx new file mode 100644 index 000000000..303d8f66d --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-value-multiple.tsx @@ -0,0 +1,30 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal(['left', 'center']); + useStyles$(styles); + + return ( +
+ (valueSelected.value = v)} + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value.join(',')} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx new file mode 100644 index 000000000..a2ed5cb97 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/test-vertical-multiple-rtl.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx new file mode 100644 index 000000000..1bf2ed8e1 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/value-bind.tsx @@ -0,0 +1,33 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx new file mode 100644 index 000000000..2bfff00a3 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/value.tsx @@ -0,0 +1,32 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + const valueSelected = useSignal('left'); + + useStyles$(styles); + + return ( +
+ { + valueSelected.value = v; + }} + > + + Left + + + Center + + + Right + + + You selected: {valueSelected.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx b/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx new file mode 100644 index 000000000..d3c9ec4f6 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/examples/vertical.tsx @@ -0,0 +1,23 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import styles from '../snippets/toggle.css?inline'; + +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + + Left + + + Center + + + Right + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/index.mdx b/apps/website/src/routes/docs/headless/toggle-group/index.mdx new file mode 100644 index 000000000..534249f46 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/index.mdx @@ -0,0 +1,213 @@ +--- +title: Qwik UI | Toggle Group +--- + +import { statusByComponent } from '~/_state/component-statuses'; + + + +# Toggle Group + +A set of two-state buttons that can be toggled on or off. + + + +## ✨ Features + + + +## Building blocks + + + +### 🎨 Anatomy + + + +## Usage / Component State + +### Multiple selection + +Pass a `multiple` prop to enable multi-selection. + + + +### Initial Value (Uncontrolled) + +An initial, uncontrolled value can be provided using the `value` prop. + + + +If you want to have some control when an item is selected, like making some side effect you can use +the `onChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). + + + +### Disabled + +Pass the `disabled` prop. + + + +You can also disabled specific items, pass the `disabled` prop at the `ToggleGroup.Item` level: +When navigating with key arrows, the disabled item will be skipped. + + + +### Looping Enabled + +Pass the `loop` prop. When enabled, keyboard navigation will loop from last item to first, and vice versa. +If one item is disabled it will skip it. + + + +If one item is disabled it will skip it. + + + +### Vertical Orientation + +Pass the `orientation` prop. + + + +### Right-to-Left (rtl) Direction + +Pass the `direction` prop to `rtl`. + + + +## Accessibility + +### Keyboard interaction + + + +## API + +### ToggleGroup.Root + + void>', + description: 'Called when the state changes.', + }, + { + name: 'bind:value', + type: 'Signal', + description: 'Reactive value (signal) to make the pressed state controlled.', + }, + { + name: 'disabled', + type: 'boolean', + description: 'Disables all items.', + }, + { + name: 'loop', + type: 'boolean', + description: + 'Enable looping when navigating with the keyboard. Default to `false`.', + }, + { + name: 'orientation', + type: '"horizontal" | "vertical"', + description: 'Choose the orientation of the toggle items. Default to "horizontal".', + }, + { + name: 'direction', + type: '"ltr" | "rtl"', + description: 'Choose the direction of the toggle items. Default to "ltr".', + }, + ]} +/> + +### ToggleGroup.Item + + diff --git a/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx b/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx new file mode 100644 index 000000000..0f62459ab --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/snippets/building-blocks.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css b/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css new file mode 100644 index 000000000..9500c9dfb --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle-group/snippets/toggle.css @@ -0,0 +1,65 @@ +.toggle-container { + font-size: 15px; + font-weight: 500; + line-height: 35px; + color: hsl(var(--foreground)); + display: flex; + flex-direction: column; +} + +.toggle { + width: auto; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0 10px; + height: 35px; + font-size: 15px; + line-height: 1; + background-color: hsl(var(--background)); + border: 2px dotted hsla(var(--primary) / 1); + border-radius: 0; +} + +/* Default background when aria-pressed is false */ +.toggle[aria-pressed='false'] { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} +/* Ensure focus or active state does not override when aria-pressed is false */ +.toggle[aria-pressed='false']:focus, +.toggle[aria-pressed='false']:active { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} + +.toggle:hover { + background-color: hsla(var(--primary) / 0.08); +} + +/* Focused and Pressed states */ +.toggle:focus-visible { + outline: 2px solid hsla(var(--primary)); + outline-offset: 2px; +} + +.toggle:focus, +.toggle:active { + background-color: hsla(var(--primary)); + color: white; +} + +/* When the toggle is pressed */ +.toggle[aria-pressed='true'] { + background-color: hsla(var(--primary)) !important; + color: white; +} + +.toggle[aria-pressed='true']:focus { + outline: 2px solid hsla(var(--secondary)); +} + +[data-disabled] { + opacity: 0.6; + background: hsl(var(--foreground) / 0.05); +} diff --git a/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx new file mode 100644 index 000000000..8d49106f3 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/bind-pressed.tsx @@ -0,0 +1,27 @@ +import { component$, useComputed$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx b/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx new file mode 100644 index 000000000..a97560b91 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/disabled.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx b/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx index ac3059fcc..c08d3df45 100644 --- a/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx +++ b/apps/website/src/routes/docs/headless/toggle/examples/hero.tsx @@ -1,10 +1,12 @@ -import { $, component$ } from '@builder.io/qwik'; +import { component$, useStyles$ } from '@builder.io/qwik'; import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; export default component$(() => { + useStyles$(styles); return ( - <> - console.log('Toggle'))} /> - +
+ Hello +
); }); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx new file mode 100644 index 000000000..062a747f2 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/initialPressed.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx new file mode 100644 index 000000000..4a6976ea4 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/pressed.tsx @@ -0,0 +1,22 @@ +import { component$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const text = useSignal('Unpress me'); + return ( +
+ + p ? (text.value = 'Unpress me') : (text.value = 'Press me') + } + class="toggle" + > + Hello + + {text.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx new file mode 100644 index 000000000..5045d581a --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-bind-pressed.tsx @@ -0,0 +1,28 @@ +import { component$, useComputed$, useSignal, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx new file mode 100644 index 000000000..49d4c3b45 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-disabled-pressed.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx b/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx new file mode 100644 index 000000000..819512528 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/examples/test-pressed-disabled.tsx @@ -0,0 +1,14 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import styles from '../snippets/toggle.css?inline'; + +export default component$(() => { + useStyles$(styles); + return ( +
+ + Hello + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/toggle/index.mdx b/apps/website/src/routes/docs/headless/toggle/index.mdx index ef32861ef..4c43d688f 100644 --- a/apps/website/src/routes/docs/headless/toggle/index.mdx +++ b/apps/website/src/routes/docs/headless/toggle/index.mdx @@ -8,17 +8,49 @@ import { statusByComponent } from '~/_state/component-statuses'; # Toggle -A toggle component is a primitive that us used to quickly switch between two possible states. +A two-state button that can be either on or off. +## ✨ Features + + + ## Building blocks -## Examples +## Usage / Component State + +### Initial Value (Uncontrolled) + +An initial, uncontrolled value can be provided using the `pressed` prop. + + + +If you want to have some control when the toggle is pressed, like making some side effect you can use +the `onPressedChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). -### WIP + + +### Disabled + +Pass the `disabled` prop. + + ## Accessibility @@ -28,45 +60,40 @@ A toggle component is a primitive that us used to quickly switch between two pos keyDescriptors={[ { keyTitle: 'Space', - description: 'Toggle between states', + description: 'Toggles between states.', }, { - keyTitle: 'Tab', - description: 'Moves focus to the next focusable element.', - }, - { - keyTitle: 'Shift + Tab', - description: 'Moves focus to the previous focusable element.', + keyTitle: 'Enter', + description: 'Toggles between states.', }, ]} /> ## API -### Toggle - void>', + description: 'Called when the state changes.', }, { - name: 'onClick$', - info: 'PropFunction<() => void>', - type: 'function', - description: 'A custom click handler to wire to the toggle click event', + name: 'bind:pressed', + type: 'Signal', + description: 'Reactive value (signal) to make the pressed state controlled.', }, { name: 'disabled', type: 'boolean', - description: 'Sets whether the toggle is disabled or not', + description: 'Disables the toggle making the toggle unpressable.', }, ]} /> diff --git a/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx b/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx index be31c88c9..86aa2efc6 100644 --- a/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx +++ b/apps/website/src/routes/docs/headless/toggle/snippets/building-blocks.tsx @@ -1,7 +1,6 @@ -import { component$, useSignal } from '@builder.io/qwik'; +import { component$ } from '@builder.io/qwik'; import { Toggle } from '@qwik-ui/headless'; export default component$(() => { - const toggleChecked = useSignal(false); - return ; + return Hello; }); diff --git a/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css b/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css new file mode 100644 index 000000000..90b375262 --- /dev/null +++ b/apps/website/src/routes/docs/headless/toggle/snippets/toggle.css @@ -0,0 +1,66 @@ +.toggle-container { + font-size: 15px; + font-weight: 500; + line-height: 35px; + color: hsl(var(--foreground)); + display: flex; + flex-direction: column; + align-items: center; +} + +.toggle { + width: auto; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0 10px; + height: 35px; + font-size: 15px; + line-height: 1; + background-color: hsl(var(--background)); + border: 2px dotted hsla(var(--primary) / 1); + border-radius: 0; +} + +/* Default background when aria-pressed is false */ +.toggle[aria-pressed='false'] { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} +/* Ensure focus or active state does not override when aria-pressed is false */ +.toggle[aria-pressed='false']:focus, +.toggle[aria-pressed='false']:active { + background-color: hsl(var(--background)) !important; + color: hsl(var(--foreground)); +} + +.toggle:hover { + background-color: hsla(var(--primary) / 0.08); +} + +/* Focused and Pressed states */ +.toggle:focus-visible { + outline: 2px solid hsla(var(--primary)); + outline-offset: 2px; +} + +.toggle:focus, +.toggle:active { + background-color: hsla(var(--primary)); + color: white; +} + +/* When the toggle is pressed */ +.toggle[aria-pressed='true'] { + background-color: hsla(var(--primary)) !important; + color: white; +} + +.toggle[aria-pressed='true']:focus { + outline: 2px solid hsla(var(--secondary)); +} + +[data-disabled] { + opacity: 0.6; + background: hsl(var(--foreground) / 0.05); +} diff --git a/apps/website/src/routes/docs/styled/menu.md b/apps/website/src/routes/docs/styled/menu.md index 260e7af50..c813c7584 100644 --- a/apps/website/src/routes/docs/styled/menu.md +++ b/apps/website/src/routes/docs/styled/menu.md @@ -35,3 +35,5 @@ - [Skeleton](/docs/styled/skeleton) - [Tabs](/docs/styled/tabs) - [Textarea](/docs/styled/textarea) +- [Toggle](/docs/styled/toggle) +- [ToggleGroup](/docs/styled/toggle-group) diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx new file mode 100644 index 000000000..ae0a5b661 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/disabled.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx new file mode 100644 index 000000000..5f7a89437 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/hero.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx b/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx new file mode 100644 index 000000000..ba44dcaea --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/examples/multiple.tsx @@ -0,0 +1,18 @@ +import { component$ } from '@builder.io/qwik'; +import { ToggleGroup } from '~/components/ui'; + +export default component$(() => { + return ( + + + Left + + + Center + + + Right + + + ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle-group/index.mdx b/apps/website/src/routes/docs/styled/toggle-group/index.mdx new file mode 100644 index 000000000..7ee702d5c --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle-group/index.mdx @@ -0,0 +1,120 @@ +--- +title: Qwik UI | Styled Toggle Group Component +--- + +import { statusByComponent } from '~/_state/component-statuses'; + + + +# Toggle Group + +A set of two-state buttons that can be toggled on or off. + + + +You can choose a multiple selection variant: + + + +And here is the default look for a disabled `ToggleGroup.Root`: + + + + + To see all features and the API checkout our + + Headless component + + + +## Installation + +**Run the following cli command or copy/paste the component code into your project** + +```sh +qwik-ui add toggle-group +``` + +```tsx +import { + component$, + type PropsOf, + Slot, + useContext, + useContextProvider, +} from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { ToggleGroup as HeadlessToggleGroup } from '@qwik-ui/headless'; + +import { toggleVariants } from '@qwik-ui/styled'; +import type { VariantProps } from 'class-variance-authority'; + +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupStyledContextId = createContextId( + 'qui-toggle-group-styled', +); + +export type ToggleGroupStyledContext = VariantProps; + +type ToggleGroupRootProps = PropsOf & + VariantProps; + +const Root = component$(({ size, look, ...props }) => { + const contextStyled: ToggleGroupStyledContext = { + size, + look, + }; + useContextProvider(toggleGroupStyledContextId, contextStyled); + + return ( + + + + ); +}); + +type ToggleGroupItemProps = PropsOf & + VariantProps; + +const Item = component$(({ ...props }) => { + const { size, look } = useContext(toggleGroupStyledContextId); + + return ( + + + + ); +}); + +export const ToggleGroup = { + Root, + Item, +}; +``` + +## Usage + +```tsx +import { ToggleGroup } from '~/components/ui'; +``` + +```tsx + + + Left + + + Center + + + Right + + +``` diff --git a/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx new file mode 100644 index 000000000..1f5aecfa7 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/bind-pressed.tsx @@ -0,0 +1,26 @@ +import { component$, useComputed$, useSignal } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + const pressedState = useSignal(true); + + const text = useComputed$(() => { + return pressedState.value ? 'You pressed me' : 'You unpressed me'; + }); + + return ( +
+ + Hello + + + {text.value} + +
+ ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx b/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx new file mode 100644 index 000000000..025f0e29f --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/disabled.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx b/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx new file mode 100644 index 000000000..85ca8dfdf --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/hero.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx new file mode 100644 index 000000000..74d80ae04 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/initialPressed.tsx @@ -0,0 +1,6 @@ +import { component$ } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + return Hello; +}); diff --git a/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx b/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx new file mode 100644 index 000000000..4ae94e401 --- /dev/null +++ b/apps/website/src/routes/docs/styled/toggle/examples/pressed.tsx @@ -0,0 +1,19 @@ +import { component$, useSignal } from '@builder.io/qwik'; +import { Toggle } from '~/components/ui'; + +export default component$(() => { + const text = useSignal('Unpress me'); + return ( +
+ + p ? (text.value = 'Unpress me') : (text.value = 'Press me') + } + > + Hello + + {text.value} +
+ ); +}); diff --git a/apps/website/src/routes/docs/styled/toggle/index.mdx b/apps/website/src/routes/docs/styled/toggle/index.mdx index 7e84a095b..1aeeeeb1f 100644 --- a/apps/website/src/routes/docs/styled/toggle/index.mdx +++ b/apps/website/src/routes/docs/styled/toggle/index.mdx @@ -4,8 +4,95 @@ title: Qwik UI | Styled Toggle Component import { statusByComponent } from '~/_state/component-statuses'; + + # Toggle +A two-state button that can be either on or off. + + + In a world of endless choices, sometimes you just need a simple yes or no. The Qwik UI Styled Toggle component is a welcomed rest for the mind. - +## Installation + +**Run the following cli command or copy/paste the component code into your project** + +```sh +qwik-ui add toggle +``` + +```tsx +import { component$, type PropsOf, Slot } from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; + +export const toggleVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-[pressed=true]:bg-primary aria-[pressed=true]:text-accent-foreground', + { + variants: { + look: { + default: 'border border-input bg-transparent', + outline: + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', + }, + + size: { + default: 'h-10 px-3', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5', + }, + }, + defaultVariants: { + look: 'default', + size: 'default', + }, + }, +); + +type ToggleProps = PropsOf & VariantProps; + +export const Toggle = component$(({ size, look, ...props }) => { + return ( + + + + ); +}); +``` + +## Usage + +```tsx +import { Toggle } from '~/components/ui'; +``` + +```tsx +Hello + +If you want to have some control when the toggle is pressed, like making some side effect you can use +the `onPressedChange$`. The event is fired when the user toggle the button, and receives the new value. + + + +### Reactive Value (Controlled) + +Pass a signal to `bind:value` prop to make the pressed state controlled (binding the value with a signal). + + + +### Disabled + +Pass the `disabled` prop. + + diff --git a/cla-signs/v1/cla.json b/cla-signs/v1/cla.json index 72d09f8e6..0b0d0abf4 100644 --- a/cla-signs/v1/cla.json +++ b/cla-signs/v1/cla.json @@ -569,4 +569,4 @@ "pullRequestNo": 957 } ] -} \ No newline at end of file +} diff --git a/packages/kit-headless/src/components/toggle-group/index.tsx b/packages/kit-headless/src/components/toggle-group/index.tsx new file mode 100644 index 000000000..fc5b18aa7 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/index.tsx @@ -0,0 +1,6 @@ +import { HToggleGroupItem } from './toggle-group-item'; +import { HToggleGroupRoot } from './toggle-group-root'; +export const ToggleGroup = { + Root: HToggleGroupRoot, + Item: HToggleGroupItem, +}; diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx new file mode 100644 index 000000000..776536bc0 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-context.tsx @@ -0,0 +1,34 @@ +import type { QRL, Signal } from '@builder.io/qwik'; +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupRootApiContextId = createContextId( + 'qui-toggle-group-root-api', +); + +export type Orientation = 'horizontal' | 'vertical'; +export type Direction = 'ltr' | 'rtl'; + +export type ItemId = string; +export type Item = { + itemId: ItemId; + ref: Signal; + isPressed: Signal; + isDisabled: boolean; + tabIndex: Signal; +}; + +export type ToggleGroupRootApiContext = { + rootId: string; + rootOrientation: Orientation; + rootDirection: Direction; + rootIsDisabled: boolean; + rootIsLoopEnabled: boolean; + rootMultiple: boolean; + activateItem$: QRL<(itemValue: string) => Promise | void>; + deActivateItem$: QRL<(itemValue: string) => Promise | void>; + getAllItem$: QRL<() => Item[]>; + pressedValuesSig: Signal; + getAndSetTabIndexItem$: QRL<(itemId: ItemId, tabIndexValue: 0 | -1) => void>; + registerItem$: QRL<(itemId: ItemId, itemSig: Signal) => void>; + itemsCSR: Signal; +}; diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx new file mode 100644 index 000000000..d690ef9fe --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-item.tsx @@ -0,0 +1,244 @@ +import type { PropsOf } from '@builder.io/qwik'; +import { + component$, + useContext, + Slot, + $, + useId, + useSignal, + useTask$, +} from '@builder.io/qwik'; +import { Toggle } from '@qwik-ui/headless'; +import { + Direction, + Item, + Orientation, + toggleGroupRootApiContextId, +} from './toggle-group-context'; +import { KeyCode } from '../../utils'; +import { isBrowser, isServer } from '@builder.io/qwik/build'; + +type NavigationKeys = + | KeyCode.ArrowRight + | KeyCode.ArrowLeft + | KeyCode.ArrowDown + | KeyCode.ArrowUp; + +type Step = -1 | 0 | 1; + +const keyNavigationMap: Record< + Orientation, + Record> +> = { + horizontal: { + ltr: { + ArrowRight: 1, + ArrowLeft: -1, + ArrowDown: 0, + ArrowUp: 0, + }, + rtl: { + ArrowRight: -1, + ArrowLeft: 1, + ArrowDown: 0, + ArrowUp: 0, + }, + }, + vertical: { + ltr: { + ArrowDown: 1, + ArrowUp: -1, + ArrowRight: 0, + ArrowLeft: 0, + }, + rtl: { + ArrowDown: -1, + ArrowUp: 1, + ArrowRight: 0, + ArrowLeft: 0, + }, + }, +}; + +type ToggleGroupItemProps = PropsOf & { + value: string; +}; + +export const HToggleGroupItem = component$((props) => { + const { value, disabled: itemDisabled = false, ...itemProps } = props; + + const rootApiContext = useContext(toggleGroupRootApiContextId); + + const disabled = rootApiContext.rootIsDisabled || itemDisabled; + + const itemId = useId(); + const isPressedSig = useSignal(false); + const itemRef = useSignal(); + const itemTabIndex = useSignal(isPressedSig.value ? 0 : -1); + + const itemSig = useSignal(() => ({ + itemId: itemId, + isPressed: isPressedSig, + isDisabled: disabled, + ref: itemRef, + tabIndex: itemTabIndex, + })); + + useTask$(async ({ track }) => { + const pressedValue = track(() => rootApiContext.pressedValuesSig.value); + + if (pressedValue == null) { + itemSig.value.isPressed.value = false; + return; + } + + if (typeof pressedValue === 'string') { + itemSig.value.isPressed.value = pressedValue === value; + } else { + itemSig.value.isPressed.value = pressedValue.includes(value); + } + }); + + //Item instantiation + useTask$(async () => { + /* + Instatiation of items with their itemIds + Attention: in CSR, items are registered "out of order" (itemId generation) + you can notice: + - the first itemId "generate" before the useTask is wrong + - the itemId read within this useTask is not the same as the one read locally. + + Still, the order how items render is correct. + + So we doing stuff on the client (CSR, onKeyDown, etc) + we can't use rootApiContext.getAllItem$() as we get Items "out of order"). + Perhaps this can be fix in v2? + + Solution: if we want to get the list of items in order, we need to use "refs" directly. + Meaning we need to use this api: rootApiContext.itemsCSR + */ + + //Note: this line execute X times in a row. (X = number of items) + await rootApiContext.registerItem$(itemId, itemSig); + + //setup the tabIndex for each item + const allItems = await rootApiContext.getAllItem$(); + + if (isBrowser) return; + + //ensure each pressedItems have tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + return currentPressedItems.forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, 0); + }); + } + + //ensure the first item that is not disabled have tabIndex = 0 + const firstNotDisabledItem = allItems.find((item) => item.isDisabled === false); + + if (firstNotDisabledItem !== undefined) { + await rootApiContext.getAndSetTabIndexItem$(firstNotDisabledItem.itemId, 0); + } + }); + + //instantiate setTabIndex for CSR + useTask$(async ({ track }) => { + if (isServer) return; + track(() => itemRef.value); + + //register refs to the Root + if (!itemRef.value) return; + rootApiContext.itemsCSR.value = [...rootApiContext.itemsCSR.value, itemRef.value]; + + if ( + rootApiContext.itemsCSR.value.length === (await rootApiContext.getAllItem$()).length + ) { + const allItems = rootApiContext.itemsCSR.value; + + //ensure each pressedItems have tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.ariaPressed === 'true'); + + if (currentPressedItems.length > 0) { + return currentPressedItems.forEach(async (item) => { + const itemRef = allItems.find((i) => i.id === item.id); + if (!itemRef) throw 'Item Not Found'; + itemRef.tabIndex = 0; + }); + } + + //ensure the first item that is not disabled have tabIndex = 0 + const firstNotDisabledItem = allItems.find((item) => item.ariaDisabled === 'false'); + + if (firstNotDisabledItem !== undefined) { + firstNotDisabledItem.tabIndex = 0; + } + } + }); + + const handlePressed$ = $((pressed: boolean) => { + if (pressed) { + rootApiContext.activateItem$(value); + } else { + rootApiContext.deActivateItem$(value); + } + }); + + const handleKeyDown$ = $(async (event: KeyboardEvent) => { + //Note: here we can't use use rootApiContext.items.value as when instantiante its [] + //we might need to make a QRL same as "rootApiContext.getAllItems$()" + const items = Array.from( + document.querySelectorAll(`.toggle-group-item-${rootApiContext.rootId}`), + ) as HTMLElement[]; + + if (items.length === 0) return; + + const enabledItems = items.filter((item) => item.ariaDisabled === 'false'); + //each item has an id (see below the Toggle JSX output) + const currentElement = event.target as HTMLElement; + const currentIndex = enabledItems.findIndex((e) => e.id === currentElement.id); + + if (currentIndex === -1) return; + + //read the direction for the key based on the orientation + const direction = + keyNavigationMap[rootApiContext.rootOrientation][rootApiContext.rootDirection][ + event.key as NavigationKeys + ]; + + //find and nextFocus + if (direction !== 0) { + let nextIndex = currentIndex + direction; + if (rootApiContext.rootIsLoopEnabled) { + // If looping is enabled, wrap around, skipping disabled items + nextIndex = + (currentIndex + direction + enabledItems.length) % enabledItems.length; + } else { + // If looping is disabled, clamp to valid indices + if (nextIndex >= enabledItems.length) nextIndex = enabledItems.length - 1; + if (nextIndex < 0) nextIndex = 0; + } + enabledItems[nextIndex]?.focus(); + } + }); + + return ( + + + + ); +}); diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx b/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx new file mode 100644 index 000000000..37627c800 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group-root.tsx @@ -0,0 +1,284 @@ +import type { PropsOf, QRL, Signal } from '@builder.io/qwik'; +import { component$, useContextProvider, Slot, useTask$, $ } from '@builder.io/qwik'; +import { + toggleGroupRootApiContextId, + type Direction, + type Orientation, + type ToggleGroupRootApiContext, +} from './toggle-group-context'; +import { useToggleGroup } from './use-toggle'; +import { isBrowser, isServer } from '@builder.io/qwik/build'; + +export type ToggleGroupBaseProps = { + /** + * When true, prevents the user from interacting with the toggle group and all its items. + */ + disabled?: boolean; +}; + +type ToggleGroupNavigationProps = { + /** + * The orientation of the component, which determines how focus moves: + * horizontal for left/right arrows and vertical for up/down arrows. + * Default to (left-to-right) reading mode. + */ + orientation?: Orientation; + /** + * The reading direction of the toggle group. + * Default to (left-to-right) reading mode. + */ + direction?: Direction; + /** + * When true + * keyboard navigation will loop from last item to first, and vice versa. + */ + loop?: boolean; +}; + +export type ToggleGroupSingleProps = { + /** + * Determines if multi selection is enabled. + */ + multiple?: false; + /** + * The initial value of the pressed item (uncontrolled). + * Can be used in conjunction with onChange$. + */ + value?: string; + + /** + * The callback that fires when the value of the toggle group changes. + * Event handler called when the pressed state of an item changes. + */ + onChange$?: QRL<(value: string) => void>; + /** + * The reactive value (a signal) of the pressed item (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:value'?: Signal; +}; + +export type ToggleGroupMultipleProps = { + /** + * Determines if multi selection is enabled. + */ + multiple?: true; + /** + * The initial value of the pressed item (uncontrolled). + * Can be used in conjunction with onChange$. + */ + value?: string[]; + /** + * The callback that fires when the value of the toggle group changes. + * Event handler called when the pressed state of an item changes. + */ + onChange$?: QRL<(value: string[]) => void>; + /** + * The reactive value (a signal) of the pressed item (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:value'?: Signal; +}; + +export type ToggleGroupApiProps = (ToggleGroupSingleProps | ToggleGroupMultipleProps) & + ToggleGroupBaseProps & + ToggleGroupNavigationProps; + +export type ToggleGroupRootProps = PropsOf<'div'> & ToggleGroupApiProps; + +export const HToggleGroupRoot = component$((props) => { + const { + onChange$: _, + disabled = false, + orientation = 'horizontal', + direction = 'ltr', + loop = false, + ...divProps + } = props; + + const commonProps = { role: 'group', 'aria-orientation': orientation, dir: direction }; + const orientationClass = orientation === 'vertical' ? 'flex-col' : 'flex-row'; + + const api = useToggleGroup(props); + + const rootApiContext: ToggleGroupRootApiContext = { + rootId: api.rootId, + rootOrientation: orientation, + rootDirection: direction, + rootIsDisabled: disabled, + rootIsLoopEnabled: loop, + rootMultiple: api.multiple, + activateItem$: api.activateItem$, + deActivateItem$: api.deActivateItem$, + getAllItem$: api.getAllItems$, + pressedValuesSig: api.pressedValuesSig, + getAndSetTabIndexItem$: api.getAndSetTabIndexItem$, + registerItem$: api.registerItem$, + itemsCSR: api.itemsCSR, + }; + + const setTabIndexInSSR = $(async () => { + const allItems = await rootApiContext.getAllItem$(); + + //if pressedItems exist, we set them to tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + currentPressedItems.forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, 0); + }); + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.isPressed.value === false) + .forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, -1); + }); + + return; + } + //However, if no pressedItems exit, we only set tabIndexx = 0 on the first item that is not disabled + const firstNotDisabledItem = allItems.find((item) => item.isDisabled === false); + + if (currentPressedItems.length === 0 && firstNotDisabledItem !== undefined) { + if (firstNotDisabledItem !== undefined) { + await rootApiContext.getAndSetTabIndexItem$(firstNotDisabledItem.itemId, 0); + } + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.itemId !== firstNotDisabledItem.itemId) + .forEach(async (item) => { + await rootApiContext.getAndSetTabIndexItem$(item.itemId, -1); + }); + + return; + } + }); + + const setTabIndexInCSR = $(async () => { + /* + Note: given a "single" toggle group with one item already pressed. + - if we use: const allItems = rootApiContext.itemsCSR.value; + - and we lookup for the currentPressedItems, we will get 2 items (the previous and the current) + For that reason to get the currentPressedItems we use: rootApiContext.getAllItem$() + However to get the firstNotDisabledItem, we need to use rootApiContext.itemsCSR.value (refs directly) + as rootApiContext.getAllItem$() will be "out of order". + + Ideally, if rootApiContext.getAllItem$() would be in appropriate order, we could use the same logic + for SSR and CSR. + In should be the case in v2, so we will refactor so both SSR and CSR will use the same API. + + + The other solution that I consider was: + to have a similar logic "setTabIndexInCSR" but this time which only use the refs + meaning (rootApiContext.itemsCSR.value) within the "toggle-group-item": + useTask$(async ({ track }) => { + if (isServer) return; + track(() => rootApiContext.pressedValuesSig.value); + await setTabIndexInCSR(); + }); + + However, I decide to use that function in Root to avoid execute that same logic X times + (X being the number of items) and the fact that Items are consumers that should work in isolation. + They should not execute logic for other Items. This is what Root should do. + */ + const allItems = await rootApiContext.getAllItem$(); + //if pressedItems exist, we set them to tabIndex = 0 + const currentPressedItems = allItems.filter((item) => item.isPressed.value === true); + + if (currentPressedItems.length > 0) { + currentPressedItems.forEach(async (item) => { + const pressedItem = allItems.find((i) => i.itemId === item.itemId); + if (!pressedItem) throw 'Item Not Found'; + if (pressedItem.ref.value) { + pressedItem.ref.value.tabIndex = 0; + } + }); + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.isPressed.value === false) + .forEach(async (item) => { + const notPressedItem = allItems.find((i) => i.itemId === item.itemId); + if (!notPressedItem) throw 'Item Not Found'; + if (notPressedItem.ref.value) { + notPressedItem.ref.value.tabIndex = -1; + } + }); + + return; + } + + //However, if no pressedItems exit, we only set tabIndexx = 0 on the first item that is not disabled + /* + Unfortunately, rootApiContext.itemsCSR.value is empty because in the toggle-group-item + the first useTask is tracking the pressedValue changes. + If we put that task at the bottom, we will get the register itemsRef in rootApiContext.itemsCSR.value. + However it will cause other missbehaviors. + + Instead the safe way is to populate manually using the "document". + In v2, we will not this all those workarounds as the items will be in order and we will use the same API for both SSR and CSR. + */ + rootApiContext.itemsCSR.value = Array.from( + document.querySelectorAll(`.toggle-group-item-${rootApiContext.rootId}`), + ) as HTMLElement[]; + + const firstNotDisabledItem = rootApiContext.itemsCSR.value.find( + (item) => item.ariaDisabled === 'false', + ); + + if (currentPressedItems.length === 0 && firstNotDisabledItem !== undefined) { + if (firstNotDisabledItem !== undefined) { + firstNotDisabledItem.tabIndex = 0; + } + + //and we ensure that the rest of items has tabIndex = -1 + allItems + .filter((item) => item.itemId !== firstNotDisabledItem.id) + .forEach(async (item) => { + const otherItem = allItems.find((i) => i.itemId === item.itemId); + if (!otherItem) throw 'Item Not Found'; + if (otherItem.ref.value) { + otherItem.ref.value.tabIndex = -1; + } + }); + + return; + } + }); + + /* + TODO: optimize this code to make it faster (its a library) + Optimization = use a for loop instead of iterating multiple times. + Status: As the ToggleGroup component is in "Draft" state, I decided to not optimize it for now. + As it will decrease readability even more. + Decision: wait for v2, to refactor the code and have the same API for both SSR and CSR. + And then make the optimization. + */ + //side-effect, to setTabIndex + useTask$(async ({ track }) => { + track(() => api.pressedValuesSig.value); + + if (isServer) { + await setTabIndexInSSR(); + } + + if (isBrowser) { + await setTabIndexInCSR(); + } + }); + + useContextProvider(toggleGroupRootApiContextId, rootApiContext); + + return ( +
+ +
+ ); +}); diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts b/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts new file mode 100644 index 000000000..e0115a684 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group.driver.ts @@ -0,0 +1,34 @@ +import { type Locator, type Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator; + }; + + const getToggleGroupRoot = () => { + return getRoot().locator('[data-qui-togglegroup-root]'); + }; + + const getItems = () => { + return getRoot().locator('[data-qui-togglegroup-item]'); + }; + + const getItemsLength = () => { + return getItems().count(); + }; + + const getItemByIndex = (index: number) => { + return getItems().nth(index); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getToggleGroupRoot, + getItems, + getItemsLength, + getItemByIndex, + }; +} diff --git a/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts b/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts new file mode 100644 index 000000000..984372580 --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/toggle-group.test.ts @@ -0,0 +1,1496 @@ +import { expect, test, type Page } from '@playwright/test'; +import { createTestDriver } from './toggle-group.driver'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`/headless/toggle-group/${exampleName}`); + + const driver = createTestDriver(page); + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is clicked + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a toggle-group + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + AND the 'center' item is clicked + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //When, Then + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //type is 'multiple' + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is clicked + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + THEN both 'center' AND right' items should have aria-pressed on true`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await rightItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' clicked + WHEN the 'right' item is clicked + AND the 'center' item is clicked + THEN 'right' item should have aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await rightItem.click(); + await centerItem.click(); + + //Then + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Uncontrolled / Initial (default props) + //single (multiple = false) + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialValue'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-initialValue-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Some control (value + onChange$) + //single + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true + AND valueSelected should be center`, async ({ page }) => { + const { driver: d } = await setup(page, 'value'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Reactive (controlled) + test(`GIVEN a toggle-group with 'bind:value' = Signal<'left'> + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on true + THEN the span element that store the value of the bounded Signal + should be updated`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-bind'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You selected: center'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + await expect(spanElement).toContainText('You selected: '); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a toggle-group with 'bind:value' = Signal<['left', 'center']> + WHEN the 'center' item is clicked + THEN 'center' item should have aria-pressed on false + THEN the span element that store the value of the bounded Signal + should be updated`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-bind-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.click(); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You selected: left'); + }); + + //disabled + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + }); + + test(`GIVEN a 'disabled' AND 'multiple' toggle-group + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = 'left' + WHEN the 'center' item is clicked (CAN'T BE CLICKED) + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is clicked + THEN data-disabled should remain on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('data-disabled'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN data-disabled should remain on the 'left' item + AND 'center' item should have aria-pressed on false + AND 'right' item should have aria-pressed on true + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is clicked + AND the 'right' item is clicked + THEN data-disabled should remain on the 'left' item + AND both 'center' AND 'right' items should have aria-pressed on false`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'test-item-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, then + await centerItem.click(); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.click(); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('data-disabled'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); +}); + +test.describe('Keyboard Navigation (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is 'Enter' pressed + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a single toggle-group wrapped into other element + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'leftItem' (firstItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-single'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + const outsideRootBottomButtonElement = await d + .getRoot() + .locator('[test-data-outside-root-bottom-button]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Tab'); + await expect(outsideRootBottomButtonElement).toBeFocused(); + }); + + test(`GIVEN a single toggle-group wrapped into other element and center item is pressed + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'center' (pressedItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-single-center-pressed'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(centerItem).toBeFocused(); + }); + + test(`GIVEN a multiple toggle-group wrapped into other element + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'leftItem' (firstItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-multiple'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '0'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('tabIndex', '-1'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + const outsideRootBottomButtonElement = await d + .getRoot() + .locator('[test-data-outside-root-bottom-button]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Tab'); + await expect(outsideRootBottomButtonElement).toBeFocused(); + }); + + test(`GIVEN a multiple toggle-group wrapped into other element and center item is pressed + WHEN the 'outsideRoot' element is 'Focused' + AND the 'outsideRoot' element is 'Tab' pressed + THEN 'center' (pressedItem) should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-focus-multiple-center-pressed'); + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toHaveAttribute('tabIndex', '-1'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('tabIndex', '0'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('tabIndex', '-1'); + + const outsideRootTopElement = await d + .getRoot() + .locator('[test-data-outside-root-top]'); + + //When, Then + await outsideRootTopElement.focus(); + await outsideRootTopElement.press('Tab'); + await expect(centerItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' item is 'Enter' pressed + AND the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //When, Then + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await rightItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //type is 'multiple' + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'center' item is 'Enter' pressed + THEN the 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' item is 'Enter' pressed + THEN both 'center' AND right' items should have aria-pressed on true`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with 'center' is 'Enter' pressed + WHEN the 'right' is 'Enter' pressed + AND the 'center' is 'Enter' pressed + THEN 'right' item should have aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + await centerItem.focus(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + //when + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await rightItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Uncontrolled / Initial value + //single (multiple = false) + test(`GIVEN a toggle-group with an initial 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialValue'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with an initial 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-initialValue-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //Initial (value) + //single + test(`GIVEN a toggle-group with 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed + THEN 'center' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'value'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //multiple + test(`GIVEN a toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-value-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await leftItem.focus(); + await leftItem.press('ArrowRight'); + await expect(centerItem).toBeFocused(); + await centerItem.press('Enter'); + + //Then + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + }); + + //disabled + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group + WHEN the 'center' item is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = 'left' + WHEN the 'center' item is 'Enter' pressed (CAN'T BE PRESSED) + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a 'disabled' toggle-group with 'value' = ['left', 'center'] + WHEN the 'center' item is 'Enter' pressed + THEN aria-disabled should be true on each item`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-value-multiple'); + + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + await expect(centerItem).toBeDisabled(); + await expect(centerItem).toHaveAttribute('aria-disabled', 'true'); + + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toBeDisabled(); + await expect(rightItem).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN aria-disabled should be true on the 'left' item + AND 'center' item should have aria-pressed on false + AND 'right' item should have aria-pressed on true + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await centerItem.press('Enter'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + + //Then + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is 'Enter' pressed + AND the 'right' item is 'Enter' pressed + THEN aria-disabled should be true on the 'left' item + AND both 'center' AND 'right' items should have aria-pressed on false`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'test-item-disabled-multiple'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //When, Then + await centerItem.focus(); + await centerItem.press('Enter'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'true'); + + await centerItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +test.describe('Keyboard Without Looping Behavior (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'left' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(leftItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + AND the 'Enter' key is pressed + THEN the 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(leftItem).toBeFocused(); + await leftItem.press('Enter'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + THEN the 'right' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + AND the 'Enter' key is pressed + THEN the 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(rightItem).toBeFocused(); + + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + //disabled (item) + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await expect(centerItem).toBeFocused(); + await centerItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); + + //vertical + test(`GIVEN a toggle-group with 'vertical' orientation + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'left' item should remain focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'vertical'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(leftItem).toBeFocused(); + }); + + //vertical and direction rtl + test(`GIVEN a toggle-group with 'vertical' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-vertical-multiple-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(centerItem).toBeFocused(); + }); + + //horizontal and direction rtl + test(`GIVEN a toggle-group with 'horizontal' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'horizontal-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); +}); + +test.describe('Keyboard With Looping Behavior (Moving and Pressing)', () => { + //'single' (multiple = false) + test(`GIVEN a toggle-group with items: left, center, right + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + AND the 'Enter' key is pressed + THEN the 'right' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + await rightItem.press('Enter'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + THEN the 'left' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(leftItem).toBeFocused(); + }); + + test(`GIVEN a toggle-group with items: left, center, right (NO LOOP) + WHEN the 'right' item is focused + AND the 'ArrowRight' key is pressed + AND the 'Enter' key is pressed + THEN the 'left' item should have aria-pressed on true`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await rightItem.focus(); + await expect(rightItem).toBeFocused(); + + await rightItem.press('ArrowRight'); + await expect(leftItem).toBeFocused(); + + await leftItem.press('Enter'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'true'); + }); + + //disabled (item) + test(`GIVEN a toggle-group with a disabled 'left' item + WHEN the 'center' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'loop-item-disabled'); + + //Given + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + + await expect(leftItem).toBeDisabled(); + await expect(leftItem).toHaveAttribute('aria-disabled', 'true'); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when + await centerItem.focus(); + await expect(centerItem).toBeFocused(); + await centerItem.press('ArrowLeft'); + await expect(rightItem).toBeFocused(); + }); + + //vertical + test(`GIVEN a toggle-group with 'vertical' orientation + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'right' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-vertical'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'ltr'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(rightItem).toBeFocused(); + }); + + //vertical and direction rtl + test(`GIVEN a toggle-group with 'vertical' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowUp' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-vertical-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'vertical'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowUp'); + await expect(centerItem).toBeFocused(); + }); + + //horizontal and direction rtl + test(`GIVEN a toggle-group with 'horizontal' orientation and 'rtl' direction + WHEN the 'left' item is focused + AND the 'ArrowLeft' key is pressed + THEN the 'center' item should be focused`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-loop-horizontal-rtl'); + + //Given + const root = d.getToggleGroupRoot(); + await expect(root).toHaveAttribute('aria-orientation', 'horizontal'); + await expect(root).toHaveAttribute('dir', 'rtl'); + await expect(d.getItems()).toHaveCount(3); + const leftItem = await d.getItemByIndex(0); + const centerItem = await d.getItemByIndex(1); + const rightItem = await d.getItemByIndex(2); + await expect(leftItem).toHaveAttribute('aria-pressed', 'false'); + await expect(centerItem).toHaveAttribute('aria-pressed', 'false'); + await expect(rightItem).toHaveAttribute('aria-pressed', 'false'); + + //when, Then + await leftItem.focus(); + await expect(leftItem).toBeFocused(); + + await leftItem.press('ArrowLeft'); + await expect(centerItem).toBeFocused(); + }); +}); diff --git a/packages/kit-headless/src/components/toggle-group/use-toggle.tsx b/packages/kit-headless/src/components/toggle-group/use-toggle.tsx new file mode 100644 index 000000000..3da84ed8f --- /dev/null +++ b/packages/kit-headless/src/components/toggle-group/use-toggle.tsx @@ -0,0 +1,120 @@ +import { $, Signal, useId, useSignal } from '@builder.io/qwik'; + +import { Item, ItemId } from './toggle-group-context'; +import { + ToggleGroupApiProps, + ToggleGroupMultipleProps, + ToggleGroupSingleProps, +} from './toggle-group-root'; +import { useBoundSignal } from '../../utils/bound-signal2'; + +function useRootItemsRepo() { + const items = useSignal>>(new Map()); + + const rootId = useId(); + + //only used to register itemRef in CSR land + const itemsCSR = useSignal([]); + + const registerItem$ = $((itemId: ItemId, itemSig: Signal) => { + items.value = items.value.set(itemId, itemSig); + }); + + const getAndSetTabIndexItem$ = $((itemId: ItemId, tabIndexValue: 0 | -1) => { + const itemSig = items.value.get(itemId); + if (!itemSig) throw 'Item Not Found'; + if (itemSig) { + itemSig.value.tabIndex.value = tabIndexValue; + } + }); + + const getAllItems$ = $(() => + Array.from(items.value.values()).map((signal) => signal.value), + ); + + return { + getAllItems$, + getAndSetTabIndexItem$, + registerItem$, + rootId, + itemsCSR, + } as const; +} + +function useCreateSingleToggleGroup(props: ToggleGroupSingleProps) { + const { multiple = false, value, onChange$, 'bind:value': givenValueSig } = props; + + const pressedValuesSig = useBoundSignal(givenValueSig, value); + const rootItemsRepo = useRootItemsRepo(); + + const handleValueChange$ = $((newValue: string) => { + pressedValuesSig.value = newValue; + + if (onChange$) onChange$(pressedValuesSig.value); + }); + + const activateItem$ = $((itemValue: string) => handleValueChange$(itemValue)); + const deActivateItem$ = $(() => handleValueChange$('')); + + return { + multiple, + pressedValuesSig, + activateItem$, + deActivateItem$, + getAllItems$: rootItemsRepo.getAllItems$, + getAndSetTabIndexItem$: rootItemsRepo.getAndSetTabIndexItem$, + registerItem$: rootItemsRepo.registerItem$, + rootId: rootItemsRepo.rootId, + itemsCSR: rootItemsRepo.itemsCSR, + } as const; +} + +function useCreateMultipleToggleGroup(props: ToggleGroupMultipleProps) { + const { multiple = true, 'bind:value': givenValueSig, value, onChange$ } = props; + + /* + Need to pass an empty array if not I got: TypeError when toggle + Uncaught (in promise) TypeError: pressedValuesSig.value is not iterable + */ + const pressedValuesSig = useBoundSignal(givenValueSig, value || []); + + const rootItemsRepo = useRootItemsRepo(); + + const handleValueChange$ = $((newValue: string[]) => { + pressedValuesSig.value = newValue; + + if (onChange$) onChange$(pressedValuesSig.value); + }); + + const activateItem$ = $((itemValue: string) => + handleValueChange$([...pressedValuesSig.value, itemValue]), + ); + const deActivateItem$ = $((itemValue: string) => + handleValueChange$(pressedValuesSig.value.filter((value) => value !== itemValue)), + ); + + return { + multiple, + pressedValuesSig, + activateItem$, + deActivateItem$, + getAllItems$: rootItemsRepo.getAllItems$, + getAndSetTabIndexItem$: rootItemsRepo.getAndSetTabIndexItem$, + registerItem$: rootItemsRepo.registerItem$, + rootId: rootItemsRepo.rootId, + itemsCSR: rootItemsRepo.itemsCSR, + } as const; +} + +function isSingleProps(props: ToggleGroupApiProps): props is ToggleGroupSingleProps { + return props.multiple === undefined || props.multiple === false; +} + +export function useToggleGroup(props: ToggleGroupApiProps) { + if (isSingleProps(props)) { + // this is fine as the ToggleGroup will always be either Single or Multiple during its lifecycle + // eslint-disable-next-line qwik/use-method-usage + return useCreateSingleToggleGroup(props); + } + return useCreateMultipleToggleGroup(props); +} diff --git a/packages/kit-headless/src/components/toggle/index.ts b/packages/kit-headless/src/components/toggle/index.tsx similarity index 100% rename from packages/kit-headless/src/components/toggle/index.ts rename to packages/kit-headless/src/components/toggle/index.tsx diff --git a/packages/kit-headless/src/components/toggle/toggle.driver.ts b/packages/kit-headless/src/components/toggle/toggle.driver.ts new file mode 100644 index 000000000..c19bb4c80 --- /dev/null +++ b/packages/kit-headless/src/components/toggle/toggle.driver.ts @@ -0,0 +1,31 @@ +import { type Locator, type Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator; + }; + + const getToggleButton = () => { + return getRoot().getByRole('button').nth(0); + }; + + const pressButtonWithEnter = () => { + getToggleButton().focus(); + return getToggleButton().press('Enter'); + }; + + const pressButtonWithSpace = () => { + getToggleButton().focus(); + return getToggleButton().press('Space'); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getToggleButton, + pressButtonWithEnter, + pressButtonWithSpace, + }; +} diff --git a/packages/kit-headless/src/components/toggle/toggle.test.ts b/packages/kit-headless/src/components/toggle/toggle.test.ts new file mode 100644 index 000000000..ced59373e --- /dev/null +++ b/packages/kit-headless/src/components/toggle/toggle.test.ts @@ -0,0 +1,299 @@ +import { expect, test, type Page } from '@playwright/test'; +import { createTestDriver } from './toggle.driver'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`/headless/toggle/${exampleName}`); + + const driver = createTestDriver(page); + + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + test(`GIVEN a toggle + WHEN the toggle is clicked + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + //Uncontrolled / Initial + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //pressed with onPressedChange to have some control + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //Controlled / Reactive + //bind:pressed: 1 way binding (reading) + test(`GIVEN a pressed toggle (with 'bind-pressed') + WHEN the toggle is clicked + THEN aria-pressed should be false + AND the span element that store the value of the bounded Signal + should be updated + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-bind-pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You unpressed me'); + }); + + //bind:pressed: 2 way binding (writing) + test(`GIVEN a pressed toggle (with 'bind-pressed') + WHEN the toggle is clicked + AND the external button is clicked + THEN aria-pressed should be true + AND the span element that store the value of the bounded Signal + should be updated + `, async ({ page }) => { + const { driver: d } = await setup(page, 'test-bind-pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + const button = await d.getRoot().locator('[test-data-bounded-button]'); + await button.click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + const spanElement = await d.getRoot().locator('[test-data-bounded-span]'); + await expect(spanElement).toContainText('You pressed me'); + }); + + //disabled + test(`GIVEN a disabled toggle + WHEN the toggle is clicked + THEN data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is clicked + THEN aria-pressed should be true + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); + + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is clicked + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('data-disabled'); + }); +}); + +test.describe('Keyboard Behavior a11y', () => { + test(`GIVEN a toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + test(`GIVEN a toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be true`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + + //when + await d.pressButtonWithSpace(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + }); + + //Uncontrolled / Initial + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + test(`GIVEN a pressed toggle (with initial 'pressed') + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'initialPressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.pressButtonWithEnter(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //Controlled / Reactive + //pressed + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + test(`GIVEN a pressed toggle (with 'pressed') + WHEN the toggle is 'Space' pressed + THEN aria-pressed should be false`, async ({ page }) => { + const { driver: d } = await setup(page, 'pressed'); + + //Given + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + + //When + await d.getToggleButton().click(); + + //Then + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'false'); + }); + + //disabled + test(`GIVEN a disabled toggle + WHEN the toggle is 'Enter' pressed + THEN aria-disabled should remain true`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Then + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled toggle + WHEN the toggle is 'Space' pressed + THEN aria-disabled should remain true`, async ({ page }) => { + const { driver: d } = await setup(page, 'disabled'); + + //Then + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-pressed'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a disabled and pressed toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-disabled-pressed'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is 'Enter' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); + test(`GIVEN a pressed and disabled toggle + WHEN the toggle is 'Space' pressed + THEN aria-pressed should remain false + AND data-disabled should remain`, async ({ page }) => { + const { driver: d } = await setup(page, 'test-pressed-disabled'); + + await expect(d.getToggleButton()).toBeDisabled(); + await expect(d.getToggleButton()).toHaveAttribute('aria-pressed', 'true'); + await expect(d.getToggleButton()).toHaveAttribute('aria-disabled', 'true'); + }); +}); diff --git a/packages/kit-headless/src/components/toggle/toggle.tsx b/packages/kit-headless/src/components/toggle/toggle.tsx index c997333c6..b023728c0 100644 --- a/packages/kit-headless/src/components/toggle/toggle.tsx +++ b/packages/kit-headless/src/components/toggle/toggle.tsx @@ -1,25 +1,71 @@ -import { PropsOf, component$, useSignal } from '@builder.io/qwik'; +import type { PropsOf, QRL, Signal } from '@builder.io/qwik'; +import { $, component$, Slot, sync$, useTask$ } from '@builder.io/qwik'; +import { useBoundSignal } from '../../utils/bound-signal2'; -export type ToggleProps = PropsOf<'input'> & { +export type ToggleProps = PropsOf<'button'> & { + /** + * When true, prevents the user from interacting with the toggle group and all its items. + */ disabled?: boolean; + /** + * The initial value of the toggle. + * Can be used in conjunction with `onPressedChange` to have more control. + */ pressed?: boolean; - defaultPressed?: boolean; + /** + * The callback that fires when the state of the toggle changes. + */ + onPressedChange$?: QRL<(pressed: boolean) => void>; + /** + * The reactive value (a signal) of the toggle (the signal is the controlled value). + * Controlling the pressed state with a bounded value. + */ + 'bind:pressed'?: Signal; }; -export const HToggle = component$( - ({ pressed, defaultPressed = false, disabled, ...props }) => { - const pressedState = useSignal(pressed || defaultPressed); - - return ( - - ); - }, -); +export const HToggle = component$((props) => { + const { + pressed: pressedProp, + onPressedChange$, + 'bind:pressed': givenValueSig, + ...buttonProps + } = props; + + const pressedSig = useBoundSignal(givenValueSig, pressedProp ? pressedProp : false); + + const handleKeyDownSync$ = sync$((event: KeyboardEvent) => { + if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) return; + + event.preventDefault(); + }); + + useTask$(async ({ track }) => { + if (pressedProp === undefined) return; + track(() => pressedProp); + pressedSig.value = pressedProp; + }); + + const handleClick$ = $(async () => { + if (!props.disabled) { + pressedSig.value = !pressedSig.value; + if (onPressedChange$) { + onPressedChange$(pressedSig.value); + } + } + }); + + return ( + + ); +}); diff --git a/packages/kit-headless/src/index.ts b/packages/kit-headless/src/index.ts index ec123fc37..77c2e051a 100644 --- a/packages/kit-headless/src/index.ts +++ b/packages/kit-headless/src/index.ts @@ -13,6 +13,7 @@ export * as Progress from './components/progress'; export * from './components/separator'; export * as Tabs from './components/tabs'; export { Toggle } from './components/toggle'; +export { ToggleGroup } from './components/toggle-group'; export * from './utils/visually-hidden'; export * as Tooltip from './components/tooltip'; export * as Dropdown from './components/dropdown'; diff --git a/packages/kit-headless/src/utils/bound-signal2.tsx b/packages/kit-headless/src/utils/bound-signal2.tsx new file mode 100644 index 000000000..d5953de5e --- /dev/null +++ b/packages/kit-headless/src/utils/bound-signal2.tsx @@ -0,0 +1,20 @@ +/** + * 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. + */ + +import { createSignal, Signal, useConstant } from '@builder.io/qwik'; + +export const useBoundSignal = ( + givenSignal?: Signal, + initialValue?: T, +): Signal => + useConstant(() => givenSignal || (createSignal(initialValue) as Signal)); diff --git a/packages/kit-styled/src/components/toggle-group/toggle-group.tsx b/packages/kit-styled/src/components/toggle-group/toggle-group.tsx new file mode 100644 index 000000000..cb732f3c8 --- /dev/null +++ b/packages/kit-styled/src/components/toggle-group/toggle-group.tsx @@ -0,0 +1,61 @@ +import { + component$, + type PropsOf, + Slot, + useContext, + useContextProvider, +} from '@builder.io/qwik'; +import { cn } from '@qwik-ui/utils'; +import { ToggleGroup as HeadlessToggleGroup } from '@qwik-ui/headless'; + +import { toggleVariants } from '@qwik-ui/styled'; +import type { VariantProps } from 'class-variance-authority'; + +import { createContextId } from '@builder.io/qwik'; + +export const toggleGroupStyledContextId = createContextId( + 'qui-toggle-group-styled', +); + +export type ToggleGroupStyledContext = VariantProps; + +type ToggleGroupRootProps = PropsOf & + VariantProps; + +const Root = component$(({ size, look, ...props }) => { + const contextStyled: ToggleGroupStyledContext = { + size, + look, + }; + useContextProvider(toggleGroupStyledContextId, contextStyled); + + return ( + + + + ); +}); + +type ToggleGroupItemProps = PropsOf & + VariantProps; + +const Item = component$(({ ...props }) => { + const { size, look } = useContext(toggleGroupStyledContextId); + + return ( + + + + ); +}); + +export const ToggleGroup = { + Root, + Item, +}; diff --git a/packages/kit-styled/src/components/toggle/toggle.tsx b/packages/kit-styled/src/components/toggle/toggle.tsx index e7716f148..ed223bc26 100644 --- a/packages/kit-styled/src/components/toggle/toggle.tsx +++ b/packages/kit-styled/src/components/toggle/toggle.tsx @@ -1,34 +1,37 @@ -import { type PropsOf, component$ } from '@builder.io/qwik'; -import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; +import { component$, type PropsOf, Slot } from '@builder.io/qwik'; import { cn } from '@qwik-ui/utils'; -import { VariantProps, cva } from 'class-variance-authority'; - -type ToggleProps = PropsOf & VariantProps; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Toggle as HeadlessToggle } from '@qwik-ui/headless'; -const toggleVariants = cva( - 'inline-flex items-center justify-center rounded-base text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground', +export const toggleVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-[pressed=true]:bg-primary aria-[pressed=true]:text-accent-foreground', { variants: { - variant: { - default: 'bg-transparent', + look: { + default: 'border border-input bg-transparent', outline: - 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', + 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', }, + size: { - default: 'h-9 px-3', - sm: 'h-8 px-2', - lg: 'h-10 px-3', + default: 'h-10 px-3', + sm: 'h-9 px-2.5', + lg: 'h-11 px-5', }, }, defaultVariants: { - variant: 'default', + look: 'default', size: 'default', }, }, ); -const Toggle = component$(({ variant, size, ...props }) => ( - -)); +type ToggleProps = PropsOf & VariantProps; -export { Toggle, toggleVariants }; +export const Toggle = component$(({ size, look, ...props }) => { + return ( + + + + ); +}); diff --git a/packages/kit-styled/src/index.ts b/packages/kit-styled/src/index.ts index a81e53d3f..6c2b24bf7 100644 --- a/packages/kit-styled/src/index.ts +++ b/packages/kit-styled/src/index.ts @@ -19,4 +19,5 @@ export * from './components/skeleton/skeleton'; export * from './components/tabs/tabs'; export * from './components/textarea/textarea'; export * from './components/toggle/toggle'; +export * from './components/toggle-group/toggle-group'; export * from './components/dropdown/dropdown'; From f59e1831b783fdb96428c1eeefb69023a7cad980 Mon Sep 17 00:00:00 2001 From: Arkadi Koifman <76536506+ArkadiK94@users.noreply.github.com> Date: Sun, 15 Sep 2024 18:19:08 +0300 Subject: [PATCH 08/27] feat: vertical carousel option (#948) * feat: add direction and maxSlideHeight props for adjusting the carousel to be vertical why: 'direction' prop for changing the direction, 'maxSlideHeight' for limiting the container of the carousel how: add these two props and change styles accordingly * chore: add directionSig to context * docs: add the vertical-direction section to docs why: so users could see that it is possible to make it vertical * feat: add the scrollable option for the vertical carousel how: check for see if it is a vertical carousel and if so adjust the scrolling direction in the scroller component * refactor: make the scroller component checks cleaner * docs: add the needed props for vertical carousel under API in docs * test: add test for swiping up vertical carousel * chore: add changeset file * chore: remove changeset file why: no need here * fix: some minor fixes * docs: place the vertical direction section in right way * fix: change the slide offsetTop of slide in vertical carousel when touch event triggered why: the index was not updated as needed * test: add touch screen test for swiping vertical carousel * test: add touchEvent tests for vertical carousel what: one test for swiping and one for check that the slide index updates --- .../headless/carousel/examples/carousel.css | 3 +- .../carousel/examples/vertical-direction.tsx | 27 ++++ .../routes/docs/headless/carousel/index.mdx | 25 +++- .../src/components/carousel/carousel.css | 6 +- .../src/components/carousel/carousel.test.ts | 119 ++++++++++++++++++ .../src/components/carousel/context.ts | 1 + .../src/components/carousel/root.tsx | 18 +++ .../src/components/carousel/scroller.tsx | 55 +++++--- 8 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx diff --git a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css index 05e3ad931..ce8bc10a9 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css +++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css @@ -9,7 +9,7 @@ .carousel-slide { border: 2px dotted hsl(var(--primary)); min-height: 10rem; - margin-top: 0.5rem; + -webkit-user-select: none; /* support for Safari */ user-select: none; } @@ -24,6 +24,7 @@ display: flex; justify-content: space-between; border: 2px dotted hsl(var(--accent)); + margin-bottom: 0.5rem; } .carousel-buttons button { diff --git a/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx b/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx new file mode 100644 index 000000000..03efc3907 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/vertical-direction.tsx @@ -0,0 +1,27 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; + + return ( + + + + {colors.map((color) => ( + + {color} + + ))} + + + ); +}); + +// internal +import styles from './carousel.css?inline'; diff --git a/apps/website/src/routes/docs/headless/carousel/index.mdx b/apps/website/src/routes/docs/headless/carousel/index.mdx index 632ff9d46..0f3e9ff17 100644 --- a/apps/website/src/routes/docs/headless/carousel/index.mdx +++ b/apps/website/src/routes/docs/headless/carousel/index.mdx @@ -50,9 +50,11 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS [data-qui-carousel-scroller] { overflow: hidden; display: flex; + flex-direction: var(--direction); gap: var(--gap); + max-height: var(--max-slide-height); /* for mobile & scroll-snap-start */ - scroll-snap-type: x mandatory; + scroll-snap-type: both mandatory; } [data-qui-carousel-slide] { @@ -67,7 +69,7 @@ On coarse devices and when getting initial slide positions, Qwik UI combines CSS @media (pointer: coarse) { [data-qui-carousel-scroller][data-draggable] { - overflow-x: scroll; + overflow: scroll; } /* make sure snap align is added after initial index animation */ @@ -140,6 +142,13 @@ To change this, use the `flex-basis` CSS property on the `` co +### Vertical Direction + +Qwik UI supports vertical carousels. +Set the `direction` prop to `column ` and define `maxSlideHeight` prop in px, for making the vertical carousel. + + + ### No Scroll Qwik UI supports carousels without a scroller, which can be useful for conditional slide carousels. @@ -318,5 +327,17 @@ In the above example, we also use the headless progress component to show the pr type: 'number', description: 'Time in milliseconds before the next slide plays during autoplay.', }, + { + name: 'direction', + type: 'union', + description: + 'Change the direction of the carousel, for it to be veritical define the maxSlideHeight prop as well.', + info: '"row" | "column"', + }, + { + name: 'maxSlideHeight', + type: 'number', + description: 'Write the height of the longest slide.', + }, ]} /> diff --git a/packages/kit-headless/src/components/carousel/carousel.css b/packages/kit-headless/src/components/carousel/carousel.css index 91aa581ef..4017377e8 100644 --- a/packages/kit-headless/src/components/carousel/carousel.css +++ b/packages/kit-headless/src/components/carousel/carousel.css @@ -2,9 +2,11 @@ [data-qui-carousel-scroller] { overflow: hidden; display: flex; + flex-direction: var(--direction); gap: var(--gap); + max-height: var(--max-slide-height); /* for mobile & scroll-snap-start */ - scroll-snap-type: x mandatory; + scroll-snap-type: both mandatory; } [data-qui-carousel-slide] { @@ -19,7 +21,7 @@ @media (pointer: coarse) { [data-qui-carousel-scroller][data-draggable] { - overflow-x: scroll; + overflow: scroll; } /* make sure snap align is added after initial index animation */ diff --git a/packages/kit-headless/src/components/carousel/carousel.test.ts b/packages/kit-headless/src/components/carousel/carousel.test.ts index c43c83881..8a8e165fd 100644 --- a/packages/kit-headless/src/components/carousel/carousel.test.ts +++ b/packages/kit-headless/src/components/carousel/carousel.test.ts @@ -444,6 +444,97 @@ test.describe('Mobile / Touch Behavior', () => { expect(Math.abs(secondSlideBox.x - scrollerBox.x)).toBeLessThan(1); // Allow 1px tolerance }); + test(`GIVEN a mobile vertical carousel + WHEN swiping to the next slide + Then the next slide should snap to the top side of the scroller`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'vertical-direction'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + const boundingBox = await d.getSlideBoundingBoxAt(0); + const cdpSession = await page.context().newCDPSession(page); + + const startY = boundingBox.y + boundingBox.height * 0.8; + const endY = boundingBox.y; + const x = boundingBox.x + boundingBox.width / 2; + + // touch events + await page.touchscreen.tap(x, startY); + + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y: startY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: endY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [{ x, y: startY }], + }); + + await page.touchscreen.tap(x, endY); + await page.touchscreen.tap(x, startY); // tap the slide to make it visible + await expect(d.getSlideAt(1)).toBeVisible(); + + await cdpSession.detach(); + const scrollerBox = await d.getScrollerBoundingBox(); + const secondSlideBox = await d.getSlideBoundingBoxAt(1); + + expect(Math.abs(secondSlideBox.y - scrollerBox.y)).toBeLessThan(1); // Allow 1px tolerance + }); + + test(`GIVEN a mobile vertical carousel + WHEN swiping two times to the next slide and clicking next button + Then the third slide should be visible`, async ({ page }) => { + const { driver: d } = await setup(page, 'vertical-direction'); + + await expect(d.getSlideAt(0)).toHaveAttribute('data-active'); + const boundingBox = await d.getSlideBoundingBoxAt(0); + const cdpSession = await page.context().newCDPSession(page); + + const startY = boundingBox.y + boundingBox.height * 0.99; + const endY = boundingBox.y; + const x = boundingBox.x + boundingBox.width / 2; + + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y: startY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: endY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [{ x, y: startY }], + }); + await expect(d.getSlideAt(1)).toBeVisible(); + + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y: startY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y: endY }], + }); + await cdpSession.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [{ x, y: startY }], + }); + + await cdpSession.detach(); + + await expect(d.getSlideAt(2)).toBeVisible(); + + await d.getNextButton().tap(); + + expect(d.getSlideAt(3)).toHaveAttribute('data-active'); + }); + test(`GIVEN a mobile carousel WHEN tapping the next button THEN the next slide should snap to the left side of the scroller`, async ({ @@ -865,6 +956,34 @@ test.describe('State', () => { await expect(progressBar).toHaveAttribute('aria-valuetext', '17%'); }); + + test(`GIVEN a carousel with direction column and max slide height declared + WHEN the swipe up or down + THEN the attribute should move to the right slide +`, async ({ page }) => { + const { driver: d } = await setup(page, 'vertical-direction'); + d; + + const visibleSlide = d.getSlideAt(0); + + const slideBox = await visibleSlide.boundingBox(); + + if (slideBox) { + const startX = slideBox.x + slideBox.width / 2; + const startY = slideBox.y + slideBox.height / 2; + + // swipe up from the middle of the visible slide + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX, -startY, { steps: 10 }); + + // finish the swiping and move the mouse back + await page.mouse.up(); + await page.mouse.move(startX, startY, { steps: 10 }); + } + // checking that the slide changed + expect(d.getSlideAt(0)).not.toHaveAttribute('data-active'); + }); }); test.describe('Stepper', () => { diff --git a/packages/kit-headless/src/components/carousel/context.ts b/packages/kit-headless/src/components/carousel/context.ts index cb47ad33d..53cfb2d07 100644 --- a/packages/kit-headless/src/components/carousel/context.ts +++ b/packages/kit-headless/src/components/carousel/context.ts @@ -24,6 +24,7 @@ export type CarouselContext = { alignSig: Signal<'start' | 'center' | 'end'>; isLoopSig: Signal; autoPlayIntervalMsSig: Signal; + directionSig: Signal<'row' | 'column'>; startIndex: number | undefined; isStepInteractionSig: Signal; }; diff --git a/packages/kit-headless/src/components/carousel/root.tsx b/packages/kit-headless/src/components/carousel/root.tsx index 6e8229581..630099028 100644 --- a/packages/kit-headless/src/components/carousel/root.tsx +++ b/packages/kit-headless/src/components/carousel/root.tsx @@ -56,6 +56,11 @@ export type CarouselRootProps = PropsOf<'div'> & { /** @internal Whether this carousel has a title */ _isTitle?: boolean; + /** The carousel's orientation */ + direction?: 'row' | 'column'; + + /** The slider height */ + maxSlideHeight?: number | undefined; /** Allows the user to navigate steps when interacting with the stepper */ stepInteraction?: boolean; }; @@ -84,6 +89,7 @@ export const CarouselBase = component$( startIndex ?? 0, ); const isScrollerSig = useSignal(false); + const directionSig = useSignal(() => props.direction ?? 'row'); const isAutoplaySig = useBoundSignal(givenAutoplaySig, false); const getInitialProgress = () => { @@ -98,6 +104,7 @@ export const CarouselBase = component$( const alignSig = useComputed$(() => props.align ?? 'start'); const isLoopSig = useComputed$(() => props.loop ?? false); const autoPlayIntervalMsSig = useComputed$(() => props.autoPlayIntervalMs ?? 0); + const maxSlideHeight = useComputed$(() => props.maxSlideHeight ?? undefined); const progressSig = useBoundSignal(givenProgressSig, getInitialProgress()); const isStepInteractionSig = useComputed$(() => props.stepInteraction ?? false); @@ -122,6 +129,7 @@ export const CarouselBase = component$( alignSig, isLoopSig, autoPlayIntervalMsSig, + directionSig, startIndex, isStepInteractionSig, }; @@ -130,6 +138,14 @@ export const CarouselBase = component$( useContextProvider(carouselContextId, context); + // Max Height needed for making vertical carousel + useTask$(({ track }) => { + track(() => maxSlideHeight.value); + if (!maxSlideHeight.value) { + directionSig.value = 'row'; + } + }); + useTask$(({ track }) => { if (!givenProgressSig) return; track(() => currentIndexSig.value); @@ -155,6 +171,8 @@ export const CarouselBase = component$( '--slides-per-view': slidesPerViewSig.value, '--gap': `${gapSig.value}px`, '--scroll-snap-align': alignSig.value, + '--direction': directionSig.value, + '--max-slide-height': `${maxSlideHeight.value}px`, }} > diff --git a/packages/kit-headless/src/components/carousel/scroller.tsx b/packages/kit-headless/src/components/carousel/scroller.tsx index 8fff99e2b..9f7a9cc62 100644 --- a/packages/kit-headless/src/components/carousel/scroller.tsx +++ b/packages/kit-headless/src/components/carousel/scroller.tsx @@ -18,13 +18,14 @@ type CarouselContainerProps = PropsOf<'div'>; export const CarouselScroller = component$((props: CarouselContainerProps) => { const context = useContext(carouselContextId); useStyles$(styles); - const startXSig = useSignal(); - const scrollLeftSig = useSignal(0); + const startPositionSig = useSignal(); + const scrollInDirectionSig = useSignal(0); const isMouseDownSig = useSignal(false); const isMouseMovingSig = useSignal(false); const isTouchDeviceSig = useSignal(false); const isTouchMovingSig = useSignal(true); const isTouchStartSig = useSignal(false); + const isHorizontal = context.directionSig.value === 'row'; useTask$(() => { context.isScrollerSig.value = true; @@ -37,29 +38,36 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { let position = 0; for (let i = 0; i < index; i++) { if (slides[i].value) { - position += slides[i].value.getBoundingClientRect().width + context.gapSig.value; + position += + slides[i].value.getBoundingClientRect()[isHorizontal ? 'width' : 'height'] + + context.gapSig.value; } } - const alignment = context.alignSig.value; if (alignment === 'center') { - position -= - (container.clientWidth - slides[index].value.getBoundingClientRect().width) / 2; + position -= isHorizontal + ? (container.clientWidth - slides[index].value.getBoundingClientRect().width) / 2 + : (container.clientHeight - slides[index].value.getBoundingClientRect().height) / + 2; } else if (alignment === 'end') { - position -= - container.clientWidth - slides[index].value.getBoundingClientRect().width; + position -= isHorizontal + ? container.clientWidth - slides[index].value.getBoundingClientRect().width + : container.clientHeight - slides[index].value.getBoundingClientRect().height; } return Math.max(0, position); }); const handleMouseMove$ = $((e: MouseEvent) => { - if (!isMouseDownSig.value || startXSig.value === undefined) return; + if (!isMouseDownSig.value || startPositionSig.value === undefined) return; if (!context.scrollerRef.value) return; - const x = e.pageX - context.scrollerRef.value.offsetLeft; + const position = isHorizontal + ? e.pageX - context.scrollerRef.value.offsetLeft + : e.pageY - context.scrollerRef.value.offsetTop; const dragSpeed = 1.75; - const walk = (x - startXSig.value) * dragSpeed; - context.scrollerRef.value.scrollLeft = scrollLeftSig.value - walk; + const walk = (position - startPositionSig.value) * dragSpeed; + context.scrollerRef.value[isHorizontal ? 'scrollLeft' : 'scrollTop'] = + scrollInDirectionSig.value - walk; isMouseMovingSig.value = true; }); @@ -70,14 +78,15 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { const container = context.scrollerRef.value; const slides = context.slideRefsArray.value; - const containerScrollLeft = container.scrollLeft; + const containerScrollInDirection = + container[isHorizontal ? 'scrollLeft' : 'scrollTop']; let closestIndex = 0; let minDistance = Infinity; for (let i = 0; i < slides.length; i++) { const slidePosition = await getSlidePosition$(i); - const distance = Math.abs(containerScrollLeft - slidePosition); + const distance = Math.abs(containerScrollInDirection - slidePosition); if (distance < minDistance) { closestIndex = i; minDistance = distance; @@ -88,6 +97,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { container.scrollTo({ left: dragSnapPosition, + top: dragSnapPosition, behavior: 'smooth', }); @@ -102,8 +112,11 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { } isMouseDownSig.value = true; - startXSig.value = e.pageX - context.scrollerRef.value.offsetLeft; - scrollLeftSig.value = context.scrollerRef.value.scrollLeft; + startPositionSig.value = isHorizontal + ? e.pageX - context.scrollerRef.value.offsetLeft + : e.pageY - context.scrollerRef.value.offsetTop; + scrollInDirectionSig.value = + context.scrollerRef.value[isHorizontal ? 'scrollLeft' : 'scrollTop']; window.addEventListener('mousemove', handleMouseMove$); window.addEventListener('mouseup', handleMouseSnap$); isMouseMovingSig.value = false; @@ -129,6 +142,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { context.scrollerRef.value.scrollTo({ left: nonDragSnapPosition, + top: nonDragSnapPosition, behavior: 'smooth', }); @@ -138,7 +152,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { const updateTouchDeviceIndex$ = $(() => { if (!context.scrollerRef.value) return; const container = context.scrollerRef.value; - const containerScrollLeft = container.scrollLeft; + const containerScrollDirection = container[isHorizontal ? 'scrollLeft' : 'scrollTop']; const slides = context.slideRefsArray.value; let currentIndex = 0; @@ -146,8 +160,10 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { slides.forEach((slideRef, index) => { if (!slideRef.value) return; - const slideLeft = slideRef.value.offsetLeft; - const distance = Math.abs(containerScrollLeft - slideLeft); + const slideInDirection = isHorizontal + ? slideRef.value['offsetLeft'] + : slideRef.value['offsetTop'] - slideRef.value.parentElement['offsetTop']; // get the offsetTop from the top of the current carousel + const distance = Math.abs(containerScrollDirection - slideInDirection); if (distance < minDistance) { minDistance = distance; currentIndex = index; @@ -165,6 +181,7 @@ export const CarouselScroller = component$((props: CarouselContainerProps) => { const newPosition = await getSlidePosition$(context.currentIndexSig.value); context.scrollerRef.value.scrollTo({ left: newPosition, + top: newPosition, behavior: 'auto', }); }); From f1ea4f33956c068ceeb49da44b2d3924153dd716 Mon Sep 17 00:00:00 2001 From: Jack Shelton <104264123+thejackshelton@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:53:10 -0500 Subject: [PATCH 09/27] feat: migrate the carousel to transforms (#963) * feat: get custom scroll for mouse and update * refactor: remove drag speed * refactor: add back drag speed * feat: one scroll model * fix: clean up a bit * fix: dumb timeouts * without animateScroll$ * does transform walking * feat: correct drag direction * initial snapping * correct snapping * works with next and prev * add will change transform * custom animations except for initial load * user defined transitions work * fix: resize * mobile sorta works * smoother on mobile * fix: simplify scroller impl * narrowed down problem * mobile working * handle x, y, or z * working in an agnostic way * cleanup markup and props * get initial slide position * refactor: add comment on qvisible * feat: no flicker getting initial slide * teeny flicker but with animations * feat: no more flicker * feat: handle initial slide pos for any orientation * feat: respect transform boundaries * fix: carousel start index * fix: progress * fix: make sure there are no duplicate handler calls * fix: respect boundaries * refactor: make carousel scroller easier to understand * fix: remove the viewport from the equation * fix: smoother swiping experience * feat: sensitivity * feat: initial move impl * fix: only calculate pos when it's not the start * feat: renders correct bullets based on slides per view * feat: proper bullet navigation * feat: bullets are smart enough to know if something is in range * feat: prev and next buttons both work with move * fix: state tests * feat: initial vertical * fix: offset for vertical version * feat: vertical with transform * fix: touch start moves according to touchY in vertical * refactor: better naming * refactor: code more easy to understand * refactored names * refactor: use props map instead * fix: initial slide position vertically * fix: don't render marker points with non-scroller carousels * feat: start docs * fix: text align * docs: add example animation * feat: upgrade carousel to beta * fix: the merge issues --- apps/website/src/_state/component-statuses.ts | 2 +- .../components/feature-list/feature-list.tsx | 2 +- .../src/routes/docs/contributing/index.mdx | 10 +- .../headless/carousel/examples/animate.tsx | 26 ++ .../headless/carousel/examples/carousel.css | 13 +- .../docs/headless/carousel/examples/move.tsx | 52 +++ .../headless/carousel/examples/reactive.tsx | 2 +- .../examples/{loop.tsx => rewind.tsx} | 2 +- .../carousel/examples/sensitivity.tsx | 33 ++ .../docs/headless/carousel/examples/start.tsx | 30 ++ .../carousel/examples/vertical-direction.tsx | 7 +- .../routes/docs/headless/carousel/index.mdx | 160 ++++--- .../src/components/carousel/bullet.tsx | 56 ++- .../src/components/carousel/carousel.css | 75 +-- .../src/components/carousel/carousel.test.ts | 436 +++++++++--------- .../src/components/carousel/context.ts | 10 +- .../src/components/carousel/next.tsx | 30 +- .../src/components/carousel/previous.tsx | 45 +- .../src/components/carousel/root.tsx | 251 +++++----- .../src/components/carousel/scroller.tsx | 293 ++++++------ .../src/components/carousel/slide.tsx | 44 +- .../src/components/carousel/use-carousel.tsx | 24 +- .../src/components/carousel/use-scroller.tsx | 156 +++++++ .../kit-headless/src/utils/bound-signal.tsx | 22 +- 24 files changed, 1146 insertions(+), 635 deletions(-) create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/animate.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/move.tsx rename apps/website/src/routes/docs/headless/carousel/examples/{loop.tsx => rewind.tsx} (94%) create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/sensitivity.tsx create mode 100644 apps/website/src/routes/docs/headless/carousel/examples/start.tsx create mode 100644 packages/kit-headless/src/components/carousel/use-scroller.tsx diff --git a/apps/website/src/_state/component-statuses.ts b/apps/website/src/_state/component-statuses.ts index 93da6bd5c..d1fdf99d7 100644 --- a/apps/website/src/_state/component-statuses.ts +++ b/apps/website/src/_state/component-statuses.ts @@ -38,7 +38,7 @@ export const statusByComponent: ComponentKitsStatuses = { }, headless: { Accordion: ComponentStatus.Beta, - Carousel: ComponentStatus.Draft, + Carousel: ComponentStatus.Beta, Collapsible: ComponentStatus.Beta, Combobox: ComponentStatus.Beta, Checkbox: ComponentStatus.Draft, diff --git a/apps/website/src/components/feature-list/feature-list.tsx b/apps/website/src/components/feature-list/feature-list.tsx index 87bce4830..6e276138c 100644 --- a/apps/website/src/components/feature-list/feature-list.tsx +++ b/apps/website/src/components/feature-list/feature-list.tsx @@ -68,7 +68,7 @@ export const FeatureList = component$((props: FeatureListProps) => { {!props.issues && ( Missing a feature? Check out the{' '} - + contributing guide {' '} and we'd be happy to review any relevant issues or PR's. Feel free to work on diff --git a/apps/website/src/routes/docs/contributing/index.mdx b/apps/website/src/routes/docs/contributing/index.mdx index 05ee6f3c3..d90b6739a 100644 --- a/apps/website/src/routes/docs/contributing/index.mdx +++ b/apps/website/src/routes/docs/contributing/index.mdx @@ -379,11 +379,7 @@ Notice how `` returns a `children` prop. This is because inline c Context and hooks is still easy to use, create a new component called `` and return that instead of the div (with the children passed between) in the example above. From there, you can use context, hooks, and all the other Qwik goodies as a top level component. ```tsx -return ( - - {props.children} - -) +return {props.children}; // use hooks, context, and other stuff here! export const ExampleBase = component$(() => { @@ -391,8 +387,8 @@ export const ExampleBase = component$(() => {
- ) -}) + ); +}); ``` ## That's it! diff --git a/apps/website/src/routes/docs/headless/carousel/examples/animate.tsx b/apps/website/src/routes/docs/headless/carousel/examples/animate.tsx new file mode 100644 index 000000000..7c634a5d7 --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/animate.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/carousel.css b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css index ce8bc10a9..b39762a16 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/carousel.css +++ b/apps/website/src/routes/docs/headless/carousel/examples/carousel.css @@ -2,13 +2,10 @@ width: 100%; } -.carousel-scroller { - margin-bottom: 0.5rem; -} - .carousel-slide { border: 2px dotted hsl(var(--primary)); min-height: 10rem; + max-height: 10rem; -webkit-user-select: none; /* support for Safari */ user-select: none; } @@ -18,6 +15,7 @@ gap: 0.5rem; padding: 1rem; border: 2px dotted hsl(var(--foreground)); + margin-top: 0.5rem; } .carousel-buttons { @@ -25,6 +23,7 @@ justify-content: space-between; border: 2px dotted hsl(var(--accent)); margin-bottom: 0.5rem; + margin-bottom: 0.5rem; } .carousel-buttons button { @@ -76,6 +75,8 @@ .carousel-stepper { display: flex; justify-content: space-between; + margin-bottom: 0.5rem; + flex-wrap: wrap; } .carousel-step { @@ -100,3 +101,7 @@ .carousel-step[data-current]::before { background-color: hsl(var(--primary)); } + +.carousel-animation { + transition: 0.35s transform cubic-bezier(0.57, 0.16, 0.95, 0.67); +} diff --git a/apps/website/src/routes/docs/headless/carousel/examples/move.tsx b/apps/website/src/routes/docs/headless/carousel/examples/move.tsx new file mode 100644 index 000000000..38accb7cb --- /dev/null +++ b/apps/website/src/routes/docs/headless/carousel/examples/move.tsx @@ -0,0 +1,52 @@ +import { component$, useStyles$ } from '@builder.io/qwik'; +import { Carousel } from '@qwik-ui/headless'; + +export default component$(() => { + useStyles$(styles); + + const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; + + useStyles$(` + .carousel-circle { + width: 20px; + height: 20px; + margin: 0 5px; + border-radius: 50%; + background-color: lightgray; + } + + .carousel-circle[data-active] { + background-color: lightblue; + } + `); + + return ( + <> + + + + {colors.map((color) => ( + + {color} + + ))} + + + + {colors.map((_, index) => { + return ( + + ); + })} + + + + ); +}); +// 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 index b05735d6b..38ed1a7a0 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/reactive.tsx +++ b/apps/website/src/routes/docs/headless/carousel/examples/reactive.tsx @@ -5,7 +5,7 @@ export default component$(() => { useStyles$(styles); const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; - const selectedIndex = useSignal(0); + const selectedIndex = useSignal(2); return ( <> diff --git a/apps/website/src/routes/docs/headless/carousel/examples/loop.tsx b/apps/website/src/routes/docs/headless/carousel/examples/rewind.tsx similarity index 94% rename from apps/website/src/routes/docs/headless/carousel/examples/loop.tsx rename to apps/website/src/routes/docs/headless/carousel/examples/rewind.tsx index 23b4588ca..10bed5d21 100644 --- a/apps/website/src/routes/docs/headless/carousel/examples/loop.tsx +++ b/apps/website/src/routes/docs/headless/carousel/examples/rewind.tsx @@ -7,7 +7,7 @@ export default component$(() => { const colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink']; return ( - +