From 86bc5a220c7bb35d005f625385b4a7f82fad770a Mon Sep 17 00:00:00 2001 From: jack shelton Date: Tue, 30 Jul 2024 14:23:57 -0500 Subject: [PATCH 1/4] allow people to pass handlers to modal close --- .../kit-headless/src/components/modal/modal-close.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kit-headless/src/components/modal/modal-close.tsx b/packages/kit-headless/src/components/modal/modal-close.tsx index 14eed7556..47bfab195 100644 --- a/packages/kit-headless/src/components/modal/modal-close.tsx +++ b/packages/kit-headless/src/components/modal/modal-close.tsx @@ -1,11 +1,15 @@ -import { PropsOf, Slot, component$, useContext } from '@builder.io/qwik'; +import { PropsOf, Slot, component$, useContext, $ } from '@builder.io/qwik'; import { modalContextId } from './modal-context'; export const HModalClose = component$((props: PropsOf<'button'>) => { const context = useContext(modalContextId); + const handleClick$ = $(() => { + context.showSig.value = false; + }); + return ( - ); From dbd0eb6c10ab05c4e7550289c107155c5c600453 Mon Sep 17 00:00:00 2001 From: jack shelton Date: Sun, 11 Aug 2024 22:36:41 -0500 Subject: [PATCH 2/4] moving to the static adapter --- 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 +- 4 files changed, 30 insertions(+), 27 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": [] From b8f99e3ba1b2c6c380345aafb3d139df3960760b Mon Sep 17 00:00:00 2001 From: Jack Shelton <104264123+thejackshelton@users.noreply.github.com> Date: Sun, 18 Aug 2024 20:21:27 -0500 Subject: [PATCH 3/4] Preview (#940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .changeset/slow-jokes-sleep.md | 5 - .github/workflows/test.yml | 2 +- .../showcase-test/component-imports.ts | 2 - apps/website/src/_state/component-statuses.ts | 2 +- .../components/code-snippet/code-snippet.tsx | 6 +- .../copy-css-config/copy-css-config.tsx | 9 +- apps/website/src/components/header/header.tsx | 13 +- .../src/components/highlight/highlight.tsx | 32 +- apps/website/src/components/icons/logo.tsx | 10 +- .../make-it-yours/make-it-yours.tsx | 29 +- .../navigation-docs/navigation-docs.tsx | 2 +- .../components/showcase/component-imports.ts | 4 - .../src/components/showcase/showcase.tsx | 15 +- apps/website/src/root.tsx | 20 +- .../docs/contributing/examples/inline.tsx | 41 +++ .../docs/contributing/examples/server.tsx | 25 ++ .../contributing/examples/the-problem.tsx | 32 ++ .../{(main) => docs}/contributing/index.mdx | 318 +++++++++++------- apps/website/src/routes/docs/headless/menu.md | 2 +- .../headless/popover/examples/corners.tsx | 4 +- .../docs/headless/popover/examples/hover.tsx | 15 - .../headless/popover/examples/placement.tsx | 4 +- .../headless/popover/examples/test-hide.tsx | 2 +- .../routes/docs/headless/popover/index.mdx | 7 +- .../headless/tooltip/examples/animation.tsx | 15 + .../tooltip/examples/arrow-styling.tsx | 19 ++ .../docs/headless/tooltip/examples/auto.tsx | 11 + .../docs/headless/tooltip/examples/basic.tsx | 14 + .../headless/tooltip/examples/complex.tsx | 22 ++ .../docs/headless/tooltip/examples/flip.tsx | 13 + .../headless/tooltip/examples/floating.tsx | 11 + .../docs/headless/tooltip/examples/gutter.tsx | 11 + .../docs/headless/tooltip/examples/hero.tsx | 13 +- .../headless/tooltip/examples/onChange.tsx | 19 ++ .../headless/tooltip/examples/placement.tsx | 38 +++ .../headless/tooltip/examples/styling.tsx | 12 + .../headless/tooltip/examples/transition.tsx | 15 + .../routes/docs/headless/tooltip/index.mdx | 238 +++++++++++-- .../headless/tooltip/snippets/animation.css | 29 ++ .../tooltip/snippets/arrow-styling.css | 38 +++ .../headless/tooltip/snippets/styling.css | 14 + .../headless/tooltip/snippets/transition.css | 16 + apps/website/src/routes/docs/styled/menu.md | 2 +- apps/website/vite.config.ts | 21 +- package.json | 6 +- packages/cli/CHANGELOG.md | 7 + packages/cli/package.json | 4 +- packages/kit-headless/CHANGELOG.md | 8 + packages/kit-headless/package.json | 2 +- .../src/components/checkbox/checkbox.test.ts | 55 ++- .../src/components/popover/popover-root.tsx | 1 + .../src/components/popover/popover.test.ts | 45 +-- ...osed-popover-popover-chrome-108-darwin.png | Bin 0 -> 8305 bytes ...ened-popover-popover-chrome-108-darwin.png | Bin 0 -> 8305 bytes .../src/components/tooltip/index.ts | 3 +- .../src/components/tooltip/tooltip-arrow.tsx | 38 +++ .../components/tooltip/tooltip-content.tsx | 10 - .../src/components/tooltip/tooltip-context.ts | 18 + .../src/components/tooltip/tooltip-panel.tsx | 23 ++ .../src/components/tooltip/tooltip-root.tsx | 98 +++++- .../components/tooltip/tooltip-trigger.tsx | 107 +++++- .../src/components/tooltip/tooltip.driver.ts | 105 ++++++ .../src/components/tooltip/tooltip.test.ts | 266 +++++++++++++++ ...osed-tooltip-popover-chrome-108-darwin.png | Bin 0 -> 8123 bytes ...ened-tooltip-popover-chrome-108-darwin.png | Bin 0 -> 10875 bytes packages/kit-headless/src/utils/test-utils.ts | 8 + packages/kit-styled/package.json | 4 +- packages/themes/.eslintrc.json | 33 ++ packages/themes/CHANGELOG.md | 1 + packages/themes/LICENSE | 21 ++ packages/themes/README.md | 13 + packages/themes/package.json | 27 ++ packages/themes/project.json | 25 ++ packages/themes/src/index.ts | 2 + packages/themes/src/lib/provider.tsx | 195 +++++++++++ packages/themes/src/lib/theme-script.tsx | 98 ++++++ packages/themes/src/lib/types.ts | 49 +++ packages/themes/src/root.tsx | 4 + packages/themes/tsconfig.json | 33 ++ packages/themes/tsconfig.lib.json | 28 ++ packages/themes/tsconfig.spec.json | 20 ++ packages/themes/type.d.ts | 4 + packages/themes/vite.config.ts | 54 +++ packages/utils/CHANGELOG.md | 6 + packages/utils/package.json | 2 +- packages/utils/src/index.ts | 1 + packages/utils/src/inline-component.ts | 48 +++ pnpm-lock.yaml | 74 ++-- tsconfig.base.json | 1 + 89 files changed, 2319 insertions(+), 400 deletions(-) delete mode 100644 .changeset/slow-jokes-sleep.md create mode 100644 apps/website/src/routes/docs/contributing/examples/inline.tsx create mode 100644 apps/website/src/routes/docs/contributing/examples/server.tsx create mode 100644 apps/website/src/routes/docs/contributing/examples/the-problem.tsx rename apps/website/src/routes/{(main) => docs}/contributing/index.mdx (50%) delete mode 100644 apps/website/src/routes/docs/headless/popover/examples/hover.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/animation.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/arrow-styling.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/auto.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/basic.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/complex.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/flip.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/floating.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/gutter.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/onChange.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/placement.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/styling.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/examples/transition.tsx create mode 100644 apps/website/src/routes/docs/headless/tooltip/snippets/animation.css create mode 100644 apps/website/src/routes/docs/headless/tooltip/snippets/arrow-styling.css create mode 100644 apps/website/src/routes/docs/headless/tooltip/snippets/styling.css create mode 100644 apps/website/src/routes/docs/headless/tooltip/snippets/transition.css create mode 100644 packages/kit-headless/src/components/popover/popover.test.ts-snapshots/closed-popover-popover-chrome-108-darwin.png create mode 100644 packages/kit-headless/src/components/popover/popover.test.ts-snapshots/opened-popover-popover-chrome-108-darwin.png create mode 100644 packages/kit-headless/src/components/tooltip/tooltip-arrow.tsx delete mode 100644 packages/kit-headless/src/components/tooltip/tooltip-content.tsx create mode 100644 packages/kit-headless/src/components/tooltip/tooltip-context.ts create mode 100644 packages/kit-headless/src/components/tooltip/tooltip-panel.tsx create mode 100644 packages/kit-headless/src/components/tooltip/tooltip.driver.ts create mode 100644 packages/kit-headless/src/components/tooltip/tooltip.test.ts create mode 100644 packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/closed-tooltip-popover-chrome-108-darwin.png create mode 100644 packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/opened-tooltip-popover-chrome-108-darwin.png create mode 100644 packages/kit-headless/src/utils/test-utils.ts create mode 100644 packages/themes/.eslintrc.json create mode 100644 packages/themes/CHANGELOG.md create mode 100644 packages/themes/LICENSE create mode 100644 packages/themes/README.md create mode 100644 packages/themes/package.json create mode 100644 packages/themes/project.json create mode 100644 packages/themes/src/index.ts create mode 100644 packages/themes/src/lib/provider.tsx create mode 100644 packages/themes/src/lib/theme-script.tsx create mode 100644 packages/themes/src/lib/types.ts create mode 100644 packages/themes/src/root.tsx create mode 100644 packages/themes/tsconfig.json create mode 100644 packages/themes/tsconfig.lib.json create mode 100644 packages/themes/tsconfig.spec.json create mode 100644 packages/themes/type.d.ts create mode 100644 packages/themes/vite.config.ts create mode 100644 packages/utils/src/inline-component.ts diff --git a/.changeset/slow-jokes-sleep.md b/.changeset/slow-jokes-sleep.md deleted file mode 100644 index 2e7fbc402..000000000 --- a/.changeset/slow-jokes-sleep.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@qwik-ui/headless': patch ---- - -fix: modal close handler onClick$ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b36d1703c..e4806d6a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,6 @@ jobs: with: node_version: 20 - run: pnpm release.prepare --parallel=false - - run: pnpm dlx pkg-pr-new@^0.0.9 publish --pnpm ./dist/packages/kit-headless ./dist/packages/kit-styled ./dist/packages/cli ./dist/packages/utils + - run: pnpm dlx pkg-pr-new publish --pnpm ./dist/packages/kit-headless ./dist/packages/kit-styled ./dist/packages/cli ./dist/packages/utils env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN is provided automatically in any repository diff --git a/apps/component-tests/src/components/showcase-test/component-imports.ts b/apps/component-tests/src/components/showcase-test/component-imports.ts index 74c9561dd..d10103125 100644 --- a/apps/component-tests/src/components/showcase-test/component-imports.ts +++ b/apps/component-tests/src/components/showcase-test/component-imports.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { isDev } from '@builder.io/qwik/build'; // The below `/src/routes/docs/**/**/examples/*.tsx` patterns are here so that import.meta.glob works both for styled and headless routes. // For example: @@ -11,7 +10,6 @@ function createMetaGlobComponents() { '../../../../website/src/routes/docs/**/**/examples/*.tsx', { import: 'default', - eager: isDev ? false : true, }, ); diff --git a/apps/website/src/_state/component-statuses.ts b/apps/website/src/_state/component-statuses.ts index 17e7479a4..bff04db40 100644 --- a/apps/website/src/_state/component-statuses.ts +++ b/apps/website/src/_state/component-statuses.ts @@ -49,6 +49,6 @@ export const statusByComponent: ComponentKitsStatuses = { Select: ComponentStatus.Beta, Separator: ComponentStatus.Beta, Tabs: ComponentStatus.Beta, - Tooltip: ComponentStatus.Draft, + Tooltip: ComponentStatus.Beta, }, }; diff --git a/apps/website/src/components/code-snippet/code-snippet.tsx b/apps/website/src/components/code-snippet/code-snippet.tsx index 3fa0cb4a3..b6d94455a 100644 --- a/apps/website/src/components/code-snippet/code-snippet.tsx +++ b/apps/website/src/components/code-snippet/code-snippet.tsx @@ -1,6 +1,5 @@ import { PropsOf, component$, useSignal, useTask$ } from '@builder.io/qwik'; import { useLocation } from '@builder.io/qwik-city'; -import { isDev } from '@builder.io/qwik/build'; import { Highlight } from '../highlight/highlight'; // The below `/src/routes/docs/**/**/snippets/*.tsx` pattern is here so that import.meta.glob works both for styled and headless routes. @@ -12,7 +11,6 @@ import { Highlight } from '../highlight/highlight'; const codeSnippets: any = import.meta.glob('/src/routes/docs/**/**/snippets/*', { query: '?raw', import: 'default', - eager: isDev ? false : true, }); type CodeSnippetProps = PropsOf<'div'> & { @@ -30,9 +28,7 @@ export const CodeSnippet = component$(({ name }) => { const codeSnippetSig = useSignal(); useTask$(async () => { - codeSnippetSig.value = isDev - ? await codeSnippets[snippetPath]() // We need to call `await codeSnippets[snippetPath]()` in development as it is `eager:false` - : codeSnippets[snippetPath]; // We need to directly access the `codeSnippets[snippetPath]` expression in preview/production as it is `eager:true` + codeSnippetSig.value = await codeSnippets[snippetPath](); // We need to call `await codeSnippets[snippetPath]()` in development as it is `eager:false` }); return ( diff --git a/apps/website/src/components/copy-css-config/copy-css-config.tsx b/apps/website/src/components/copy-css-config/copy-css-config.tsx index ef3a2d195..f4ef794e6 100644 --- a/apps/website/src/components/copy-css-config/copy-css-config.tsx +++ b/apps/website/src/components/copy-css-config/copy-css-config.tsx @@ -3,7 +3,7 @@ import { Modal } from '@qwik-ui/headless'; import { Button } from '~/components/ui'; import { extractThemeCSS } from '@qwik-ui/utils'; import { LuX } from '@qwikest/icons/lucide'; -import { useTheme } from 'qwik-themes'; +import { useTheme } from '@qwik-ui/themes'; import globalCSS from '~/global.css?raw'; import { Highlight } from '../highlight/highlight'; @@ -12,16 +12,17 @@ export default component$(() => { const cssThemeOutput = useSignal(''); - const { theme } = useTheme(); + const { themeSig, defaultTheme, storageKey } = useTheme(); return ( diff --git a/apps/website/src/components/navigation-docs/navigation-docs.tsx b/apps/website/src/components/navigation-docs/navigation-docs.tsx index d5f444deb..9078277e1 100644 --- a/apps/website/src/components/navigation-docs/navigation-docs.tsx +++ b/apps/website/src/components/navigation-docs/navigation-docs.tsx @@ -27,7 +27,7 @@ const defaultLinksGroups: LinkGroup[] = [ children: [ { name: 'Contributing', - href: '/contributing/', + href: '/docs/contributing/', }, { name: 'Headless', diff --git a/apps/website/src/components/showcase/component-imports.ts b/apps/website/src/components/showcase/component-imports.ts index a774dedae..1dfeecaff 100644 --- a/apps/website/src/components/showcase/component-imports.ts +++ b/apps/website/src/components/showcase/component-imports.ts @@ -1,5 +1,3 @@ -import { isDev } from '@builder.io/qwik/build'; - // The below `/src/routes/docs/**/**/examples/*.tsx` patterns are here so that import.meta.glob works both for styled and headless routes. // For example: // /src/routes/docs/components/styled/modal/examples/hero.tsx @@ -10,7 +8,6 @@ export const metaGlobComponents: Record = import.meta.glob( '/src/routes/docs/**/**/examples/*.tsx', { import: 'default', - eager: isDev ? false : true, }, ); @@ -20,6 +17,5 @@ export const rawComponents: Record = import.meta.glob( { query: '?raw', import: 'default', - eager: isDev ? false : true, }, ); diff --git a/apps/website/src/components/showcase/showcase.tsx b/apps/website/src/components/showcase/showcase.tsx index 5a58b15e9..aeba2405a 100644 --- a/apps/website/src/components/showcase/showcase.tsx +++ b/apps/website/src/components/showcase/showcase.tsx @@ -1,6 +1,5 @@ import { Component, component$, useSignal, useTask$ } from '@builder.io/qwik'; import { useLocation } from '@builder.io/qwik-city'; -import { isDev } from '@builder.io/qwik/build'; import { Tabs } from '@qwik-ui/headless'; import { Highlight } from '../highlight/highlight'; import { metaGlobComponents, rawComponents } from './component-imports'; @@ -19,13 +18,13 @@ export const Showcase = component$(({ name, ...props }) => { const componentCodeSig = useSignal(); useTask$(async () => { - // eslint-disable-next-line qwik/valid-lexical-scope - MetaGlobComponentSig.value = isDev - ? await metaGlobComponents[componentPath]() // We need to call `await metaGlobComponents[componentPath]()` in development as it is `eager:false` - : metaGlobComponents[componentPath]; // We need to directly access the `metaGlobComponents[componentPath]` expression in preview/production as it is `eager:true` - componentCodeSig.value = isDev - ? await rawComponents[componentPath]() - : rawComponents[componentPath]; + try { + // eslint-disable-next-line qwik/valid-lexical-scope + MetaGlobComponentSig.value = await metaGlobComponents[componentPath](); // We need to call `await metaGlobComponents[componentPath]()` in development as it is `eager:false` + componentCodeSig.value = await rawComponents[componentPath](); + } catch (e) { + throw new Error(`Unable to load path ${componentPath}`); + } }); return ( diff --git a/apps/website/src/root.tsx b/apps/website/src/root.tsx index 969cb3317..9ec36a83d 100644 --- a/apps/website/src/root.tsx +++ b/apps/website/src/root.tsx @@ -1,19 +1,16 @@ +import { component$, useContextProvider, useStore, useStyles$ } from '@builder.io/qwik'; import { - component$, - PrefetchGraph, - PrefetchServiceWorker, - useContextProvider, - useStore, - useStyles$, -} from '@builder.io/qwik'; -import { QwikCityProvider, RouterOutlet } from '@builder.io/qwik-city'; + QwikCityProvider, + RouterOutlet, + ServiceWorkerRegister, +} from '@builder.io/qwik-city'; import { APP_STATE_CONTEXT_ID } from './_state/app-state-context-id'; import { AppState } from './_state/app-state.type'; import { RouterHead } from './components/router-head/router-head'; import globalStyles from './global.css?inline'; -import { ThemeProvider } from 'qwik-themes'; +import { ThemeProvider } from '@qwik-ui/themes'; import '@fontsource-variable/inter'; import { @@ -46,11 +43,10 @@ export default component$(() => { return ( - + - - + { + const isRenderedSig = useSignal(false); + + return ( +
+ + {isRenderedSig.value && ( + + + + + + + )} +
+ ); +}); + +const ExampleRoot = ({ children }: PropsOf<'div'>) => { + let currItemIndex = 0; + + findComponent(Item, (itemProps) => { + itemProps._index = currItemIndex; + currItemIndex++; + }); + + processChildren(children); + + return
{children}
; +}; + +const Item = component$(({ _index }: { _index?: number }) => { + if (_index === undefined) { + throw new Error('Qwik UI: Example inline component cannot find its proper index.'); + } + + return
Item {_index + 1}
; +}); diff --git a/apps/website/src/routes/docs/contributing/examples/server.tsx b/apps/website/src/routes/docs/contributing/examples/server.tsx new file mode 100644 index 000000000..5297d7a57 --- /dev/null +++ b/apps/website/src/routes/docs/contributing/examples/server.tsx @@ -0,0 +1,25 @@ +import { component$, Signal, useSignal, useTask$ } from '@builder.io/qwik'; + +export default component$(() => { + const countSig = useSignal(0); + + return ( +
+ rendered on the server + + + + +
+ ); +}); + +const Item = component$(({ countSig }: { countSig: Signal }) => { + const itemNum = useSignal(0); + + useTask$(() => { + itemNum.value = ++countSig.value; + }); + + return
Item {itemNum.value}
; +}); diff --git a/apps/website/src/routes/docs/contributing/examples/the-problem.tsx b/apps/website/src/routes/docs/contributing/examples/the-problem.tsx new file mode 100644 index 000000000..918c8ad46 --- /dev/null +++ b/apps/website/src/routes/docs/contributing/examples/the-problem.tsx @@ -0,0 +1,32 @@ +import { component$, Signal, useSignal, useTask$ } from '@builder.io/qwik'; + +export default component$(() => { + const isItemsRenderedSig = useSignal(false); + const countSig = useSignal(0); + + return ( +
+ + {isItemsRenderedSig.value && ( + <> + + + + + + )} +
+ ); +}); + +const Item = component$(({ countSig }: { countSig: Signal }) => { + const itemNum = useSignal(0); + + useTask$(() => { + itemNum.value = ++countSig.value; + }); + + return
Item {itemNum.value}
; +}); diff --git a/apps/website/src/routes/(main)/contributing/index.mdx b/apps/website/src/routes/docs/contributing/index.mdx similarity index 50% rename from apps/website/src/routes/(main)/contributing/index.mdx rename to apps/website/src/routes/docs/contributing/index.mdx index 28ec1059a..f179a4b27 100644 --- a/apps/website/src/routes/(main)/contributing/index.mdx +++ b/apps/website/src/routes/docs/contributing/index.mdx @@ -3,7 +3,9 @@ title: 'Qwik UI - Contributing' --- import { FeatureList } from '~/components/feature-list/feature-list'; + import { InfoPopup } from '~/components/info-popup/info-popup'; + import { statusByComponent } from '~/_state/component-statuses'; # Contributing @@ -12,7 +14,7 @@ Thinking about contributing to the project, but don't know where to start? You'r We'll get you up in shape in no time, and ready to hop into the Qwik UI code cave. -![The Qwik UI Code Cave](/images/contributing/code-cave.webp) + ## There are **two projects** we currently work on: @@ -56,13 +58,11 @@ We also have plenty of other [accessibility resources](https://discord.com/chann ## When is a headless component beta? -### It has features - It can be used for most common use cases, and maybe some advanced ones (if you'd like to go further). -What I like to do is look at other places around the web and see how things work! What kind of features do they have? Does someone already have a need for this in the Qwik community? How would I go about approaching this? +A good place to start is look around the web and see how things work! What kind of features do other solutions have? Does someone already have a need for this in the Qwik community? How would I go about approaching this? -We also take inspiration from awesome headless libraries in other communities. For example, like the popular headless libraries below: +Feel free to take inspiration from awesome headless libraries in other communities. For example, like the popular headless libraries below: - [React Aria](https://react-spectrum.adobe.com/react-aria/components.html) is a React Headless library. - [Radix UI](https://www.radix-ui.com/primitives/docs/components/accordion) is a React Headless library. @@ -73,132 +73,25 @@ We also take inspiration from awesome headless libraries in other communities. F - [React Headless Hooks](https://webeetle.github.io/react-headless-hooks/docs/useAccordion) is a hooks based headless library for React. - [Downshift](https://www.downshift-js.com/) is a hooks based library for accessible comboboxes and select components. -I love going through these projects and understanding the why and what problems they solve. What kinda features do all of them have in common? How do they name things? What conventions do they use? How satisfied are people consuming it? - -I think this is a good way to figure out what to work on. That said, we also want to keep things simple, and not add features unless we know there is a demand for them (hence looking for similarities). - -> Ideally, we would like the surface API to be simple, but **powerful**. Post 1.0, we are also looking to refactor the components to use hook logic under the hood, and provide a hooks API to keep things as customizable as possible, giving the opportunity to use a hook or a component! - -## Tests - -Tests ensure we can sleep sound at night and know that our component behavior is working as intended. Part of the Qwik core team, Shai Reznik (and also a contributor here!) talks a lot about [test driven development](https://www.youtube.com/watch?v=KHaeVaSkhIE). - -### TDD Process - -- we need a new feature! -- make a failing test with the desired behavior (wut?) -- get the test passing by adding said feature! -- enjoy life when refactoring 🏝️ - -We strongly recommend TDD development for the headless library, and we are currently in the process of a playwright integration. - -Currently, the component testing integration for Qwik & Playwright is in development, and we are using e2e tests for the time being. That said, most tests should be very easy to migrate later on. - -### Getting started w/ testing - -Here's an example way of getting a testid of the `Hero` select docs example in `index.mdx`, without affecting any visible markup. - -```tsx -
- -
-``` - -Then, we get our testDriver, you can think of it as reusable code we use throughout the test. For example, in the Select component we constantly grab the listbox, trigger, etc. - -```tsx -import { Locator, Page } from '@playwright/test'; - -export type DriverLocator = Locator | Page; - -export function selectTestDriver(locator: T) { - const getRoot = () => { - return locator.getByRole('combobox'); - }; - - return { - ...locator, - locator, - getRoot, - getListbox() { - return getRoot().getByRole('listbox'); - }, - getTrigger() { - return getRoot().getByRole('button'); - }, - // get all options - getOptions() { - return getRoot().getByRole('option').all(); - }, - }; -} -``` - -Now we can write some tests: - -```tsx -import { test, expect } from '@playwright/test'; -import { selectTestDriver } from './select.driver'; - -test.beforeEach(async ({ page }) => { - await page.goto('/docs/headless/select'); -}); - -test.describe('critical functionality', () => { - test(`GIVEN a basic select - WHEN clicking on the trigger - THEN open up the listbox - AND aria-expanded should be true`, async ({ page }) => { - const testDriver = selectTestDriver(page.getByTestId('select-hero-test')); +Going through these projects can help with understanding the why and what problems they solve. What kinda features do all of them have in common? How do they name things? What conventions do they use? How satisfied are people consuming it? - const { getListbox, getTrigger } = testDriver; - - await getTrigger().click(); - - await expect(getListbox()).toBeVisible(); - await expect(getTrigger()).toHaveAttribute('aria-expanded', 'true'); - }); - - test(`GIVEN a basic select - WHEN focusing the trigger and hitting enter - THEN open up the listbox - AND aria-expanded should be true`, async ({ page }) => { - const testDriver = selectTestDriver(page.getByTestId('select-hero-test')); - - const { getListbox, getTrigger } = testDriver; - - await getTrigger().focus(); - await page.keyboard.press('Enter'); - - await expect(getListbox()).toBeVisible(); - await expect(getTrigger()).toHaveAttribute('aria-expanded', 'true'); - }); -}); -``` - -To run the tests, use the `pnpm playwright` (only in my branch) command, or the `nx e2e website` longform. You can also do `--ui` to open the UI mode (which is pretty awesome!) - -> This example is in `spec.select.tsx` in the `src/components/select` folder of the website. - -Once we've added a failing test with what we expect, we can now go ahead and implement the feature for that! - -Heads up, I (Jack) am also relatively new to playwright myself 😅. I'm guessing there is a way to not need the testDriver to be consumed on each test. Feel free to experiment! +It also helps to keep things simple, and not add features unless there is a demand for them (hence looking for similarities). ## Docs -We use [MDX](https://mdxjs.com/docs/what-is-mdx/) for interactive markdown. +Qwik UI uses [MDX](https://mdxjs.com/docs/what-is-mdx/) for interactive markdown. -You can find the headless docs [here](https://github.com/qwikifiers/qwik-ui/tree/main/apps/website/src/routes/docs/headless). +Here is a quick link to the [headless docs in github](https://github.com/qwikifiers/qwik-ui/tree/main/apps/website/src/routes/docs/headless). -When creating the docs, we have a `showcase` component, which gives typescript support, a component preview of your example, and **automatically** updates the code example as you edit it! 🤯 +One of the most import components in the docs is the `showcase` component, which gives typescript support, a component preview of your example, and **automatically** updates the code example as you edit it! 🤯 -> Here's [an example](https://github.com/qwikifiers/qwik-ui/blob/main/apps/website/src/routes/docs/headless/modal/examples/main.tsx) of someone consuming a headless component! In `index.mdx` we can use ``, because `main.tsx` is the file path. +> Here's [an example](https://github.com/qwikifiers/qwik-ui/blob/main/apps/website/src/routes/docs/headless/modal/examples/hero.tsx) of someone consuming a headless component! In `index.mdx` we can use ``, because `hero.tsx` is the file path. -The same thing goes for our `snippet` component, which is for showing code blocks only. +The same thing goes for the `snippet` component, which is for showing code blocks only. ### Docs Components -We also have more [docs components](https://github.com/qwikifiers/qwik-ui/tree/main/apps/website/src/components) to make your life easier! Some examples being: +There are more [docs components](https://github.com/qwikifiers/qwik-ui/tree/main/apps/website/src/components) to make your life easier! Some examples being: ### Notes @@ -242,15 +135,125 @@ We also have more [docs components](https://github.com/qwikifiers/qwik-ui/tree/m +## Tests + +Tests ensure we can sleep sound at night and know that our component behavior is working as intended. Part of the Qwik core team, Shai Reznik (and also a contributor here!) talks a lot about [test driven development](https://www.youtube.com/watch?v=KHaeVaSkhIE). + +### TDD Process + +- we need a new feature! +- make a failing test with the desired behavior (wut?) +- get the test passing by adding said feature! +- enjoy life when refactoring 🏝️ + +We strongly recommend TDD development for the headless library. + +[Playwright](https://playwright.dev/) is the tool that is used for component testing. + +### Getting started w/ testing + +Using what we've learned with the showcase component, let's create a new example to test: + +```tsx +import { component$ } from '@builder.io/qwik'; +import { Collapsible } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + + Trigger + + + Content + + + ); +}); +``` + +Above is a new file called `hero.tsx` in our `examples` folder. In the collapsible pages `index.mdx` file add the following: + +```shell + +``` + +Adding the showcase component to the website MDX will automatically create a new isolated environment in playwright as well. + +Each headless component also needs a "driver file", or the reusable component pieces we will need throughout the test. + +In the headless folder, create a new file with the convention of `.driver.tsx`. + +```tsx +import { Locator, Page } from '@playwright/test'; +export type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator.locator('[data-collapsible]'); + }; + + const getTrigger = () => { + return getRoot().getByRole('button'); + }; + + const getContent = () => { + return getRoot().locator('[data-collapsible-content]'); + }; + + return { + ...rootLocator, + locator: rootLocator, + getRoot, + getTrigger, + getContent, + }; +} +``` + +Above we are getting the collapsible root, trigger, and content. + +Now, these pieces can be used in our test file: + +```tsx +import { expect, test, type Page } from '@playwright/test'; +import { createTestDriver } from './collapsible.driver'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`headless/collapsible/${exampleName}`); + + const driver = createTestDriver(page); + + return { + driver, + }; +} + +test.describe('Mouse Behavior', () => { + test(`GIVEN a collapsible + WHEN clicking on the trigger + THEN the content should be visible`, async ({ page }) => { + const { driver: d } = await setup(page, 'hero'); + + await d.getTrigger().click(); + await expect(d.getContent()).toBeVisible(); + }); +}); +``` + +Notice that we passed the hero example to our setup function for this test. + +To run the tests, use the `pnpm test.pw.headless --ui` command. + +Once the test is failing with the intended playwright commands, it's a good time to implement the feature for that! + ### **What if I only want to do docs contributions, is that ok?** Absolutely, documentation is a critical part of the project, and something that can be very much improved! I recommend checking out [Sarah Rainsberg's Docs Guide](https://contribute.docs.astro.build/welcome/), it's partly towards Astro, but is also a great general resource for writing good documentation. ## Where should I learn the Qwik parts? -If you find yourself stuck on a certain pattern, try taking a look through Qwik UI beta components. For example, the [select component](https://github.com/qwikifiers/qwik-ui/tree/main/packages/kit-headless/src/components/select) would be a good one to look through. - -We're also happy to help! We love experimenting with things and having a blast while doing it. +If you find yourself stuck on a certain pattern, try taking a look through Qwik UI stable components. For example, the [collapsible component](https://github.com/qwikifiers/qwik-ui/tree/main/packages/kit-headless/src/components/collapsible). ### What's something I should avoid? @@ -258,7 +261,7 @@ We're also happy to help! We love experimenting with things and having a blast w You're pretty much saying "hey Qwik! All those benefits you do to lazy load and delay the execution of code? Let's throw those away". -Instead, look at it this way: +When building headless functionality, it's important to ask yourself: Where does my user interact with things? And how can I make sure that we can _delay_ the execution of that code until the user ABSOLUTELY needs it. @@ -281,7 +284,7 @@ useVisibleTask$(({ cleanup }) => { }); ``` -Because this code is directly tied to an event, we should not be using a visible task. We could achieve the same result with: +Because this code is directly tied to an event, the same result could be achieved with: ```tsx useOnWindow('resize', $(() => { @@ -292,7 +295,7 @@ useOnWindow('resize', $(() => { }); ``` -We promise that creating ui elements gets easier once you have a clear mental model for API's like `useTask$`. Here are some alternatives to explore. +Creating ui elements gets easier once you have a clear mental model for API's like `useTask$`. Here are some alternatives to explore over useVisibleTask$. - [Events](https://qwik.dev/docs/components/events/#events) - onClick$, onScroll$, onKeydown$ - [useTask$](https://qwik.dev/docs/components/tasks/#usetask) - (running code initially on server, tracked change on client) @@ -308,6 +311,69 @@ We want to squeeze as much possible performance out of Qwik, and stay with the p We cover it in-depth in the [contributing guide](https://github.com/qwikifiers/qwik-ui/blob/main/CONTRIBUTING.md) here. +## Inline Components for UI Libraries (proper indexes) + +Inline components play a crucial role in Qwik, especially when building headless UI libraries. They help solve unique challenges related to Qwik's resumable architecture and asynchronous rendering capabilities. + +### Why Use Inline Components? + +**TLDR**: Inline components can look into the children and get the proper index, pass data, or make certain API decisions. + +A more detailed explanation: + +In client-side rendered environments, such as dashboards, Qwik components can render asynchronously and even out of order. + + + +The above demonstrates the problem. The conditional JSX added is rendered on the client when the button is clicked, and the items are not rendered in the correct order. + +By contrast, when it is rendered on the server, we get the expected order. + + + +This has to do with the entrypoints of the application. The current and previous generation of frameworks execute from the root entrypoint down in a tree-like structure, which is O(n) complexity. + +Qwik components execute based on interactions, each interactive piece is a possible entry point of the application. This means that Qwik components can render asynchronously and even out of order. Manu Almeida covers these [data structures in relations to frameworks in-depth](https://www.builder.io/blog/hydration-tree-resumability-map). + +While resumability and javascript streaming offer substantial benefits, it poses a challenge when you need to maintain a specific order or index for child components when in a CSR environment. + +[Inline components](https://qwik.dev/docs/components/overview/#inline-components) help address this by: + +1. Ensuring proper indexing of child components +2. Creating boundaries for bundling related code +3. Providing a way to process children before rendering, and while QRL's are being resolved + +### How Inline Components Work + +Unlike regular Qwik components defined with `component$()`, inline components: + +- Are declared as standard functions +- Cannot use `use*` methods (e.g., `useSignal`, `useStore`) +- Cannot project content with `` +- Are bundled with their parent component + +Inline components should be used with caution. They are called inline because they are defined inline with the parent component. This means that they are bundled with the parent component, which can lead to performance issues if used excessively. + +In our case, the tradeoff is negligible, but it's something to keep in mind. + +### Adding an inline component + +To use an inline component, create a standard function that returns JSX. In the below example, the inline component is called ExampleRoot. + + + +The root component uses two utilities from Qwik UI. + +One is `findComponent`, which expects the component to be found as the first argument, and a callback function with the component's props as the second argument. The logic in this callback function is executed when the component is found. + +> findComponent can be used to find any children of our root. If we wanted to execute logic based on the existence of a new component called `` we could use `findComponent(Toggle, () => {})`. + +The other utility is `processChildren`, which allows us to search through the children of an inline component for the "outer shell" we're looking for. + +> processChildren should only be called once per inline component. + +The prop `_index` contains an underscore to emphasize that it is an internal prop. Notice how `_index` is consumed in the child Item component. + ## That's it! Hopefully you should have enough to get up and running with Qwik UI Headless, if you have any questions don't let us stop you from reaching out, and happy building :qwik: diff --git a/apps/website/src/routes/docs/headless/menu.md b/apps/website/src/routes/docs/headless/menu.md index ff6a10e43..1cd0264e3 100644 --- a/apps/website/src/routes/docs/headless/menu.md +++ b/apps/website/src/routes/docs/headless/menu.md @@ -2,7 +2,7 @@ ## Qwik UI -- [Contributing](/contributing) +- [Contributing](/docs/contributing) - [Headless](/docs/headless/introduction) - [Styled](/docs/styled/introduction) diff --git a/apps/website/src/routes/docs/headless/popover/examples/corners.tsx b/apps/website/src/routes/docs/headless/popover/examples/corners.tsx index 28cf1e47e..d1de86ed7 100644 --- a/apps/website/src/routes/docs/headless/popover/examples/corners.tsx +++ b/apps/website/src/routes/docs/headless/popover/examples/corners.tsx @@ -3,9 +3,9 @@ import { Popover } from '@qwik-ui/headless'; export default component$(() => { return ( - +
- Hover over me + Click me
I am on the top-right corner! diff --git a/apps/website/src/routes/docs/headless/popover/examples/hover.tsx b/apps/website/src/routes/docs/headless/popover/examples/hover.tsx deleted file mode 100644 index 62eb69713..000000000 --- a/apps/website/src/routes/docs/headless/popover/examples/hover.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { component$ } from '@builder.io/qwik'; -import { Popover } from '@qwik-ui/headless'; - -export default component$(() => { - return ( - -
-

I'm a mini tooltip!

- Hover over me -
- - I am anchored to the trigger! -
- ); -}); diff --git a/apps/website/src/routes/docs/headless/popover/examples/placement.tsx b/apps/website/src/routes/docs/headless/popover/examples/placement.tsx index 6628b8c38..ebc1b76f3 100644 --- a/apps/website/src/routes/docs/headless/popover/examples/placement.tsx +++ b/apps/website/src/routes/docs/headless/popover/examples/placement.tsx @@ -3,10 +3,10 @@ import { Popover } from '@qwik-ui/headless'; export default component$(() => { return ( - +

popover on the right ⤵️

- Hover over me + Click me
I am anchored to the trigger! diff --git a/apps/website/src/routes/docs/headless/popover/examples/test-hide.tsx b/apps/website/src/routes/docs/headless/popover/examples/test-hide.tsx index d00b6e61f..68bf16bd2 100644 --- a/apps/website/src/routes/docs/headless/popover/examples/test-hide.tsx +++ b/apps/website/src/routes/docs/headless/popover/examples/test-hide.tsx @@ -4,7 +4,7 @@ export default component$(() => { const { hidePopover } = usePopover('hide-id'); return ( - + Click me My Hero! diff --git a/apps/website/src/routes/docs/headless/popover/index.mdx b/apps/website/src/routes/docs/headless/popover/index.mdx index 9684301ec..adeefe34a 100644 --- a/apps/website/src/routes/docs/headless/popover/index.mdx +++ b/apps/website/src/routes/docs/headless/popover/index.mdx @@ -3,6 +3,7 @@ title: Qwik UI | Popover --- import { statusByComponent } from '~/_state/component-statuses'; + import styles from './snippets/popover.css'; @@ -273,12 +274,6 @@ Instead, the popover will be fixed position, and you can use CSS to position it. The `Popover.Root` component is designed for positioning elements that float and facilitating interactions with them. -### Hover - -If we'd like to show the `Popover.Panel` on hover, we can use the `hover` prop. - - - ### Custom Floating Position By default, popovers will float below the trigger component. diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/animation.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/animation.tsx new file mode 100644 index 000000000..45321b077 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/animation.tsx @@ -0,0 +1,15 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +import '../snippets/animation.css'; + +export default component$(() => { + return ( + + Hover or Focus me + + Animated tooltip content here + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/arrow-styling.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/arrow-styling.tsx new file mode 100644 index 000000000..b9a88351d --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/arrow-styling.tsx @@ -0,0 +1,19 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +import '../snippets/arrow-styling.css'; + +export default component$(() => { + return ( + + Hover or Focus me + + +
+

Tooltip Title

+

This tooltip has a snazzy styled arrow!

+
+
+
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/auto.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/auto.tsx new file mode 100644 index 000000000..f67d12917 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/auto.tsx @@ -0,0 +1,11 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + Tooltip content here + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/basic.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/basic.tsx new file mode 100644 index 000000000..0896bafa6 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/basic.tsx @@ -0,0 +1,14 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + + + Tooltip content here + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/complex.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/complex.tsx new file mode 100644 index 000000000..8f43ad4ef --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/complex.tsx @@ -0,0 +1,22 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + + +
+

Tooltip Title

+

This is a tooltip with complex HTML content, including:

+
    +
  • List item 1
  • +
  • List item 2
  • +
  • List item 3
  • +
+
+
+
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/flip.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/flip.tsx new file mode 100644 index 000000000..555a163ed --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/flip.tsx @@ -0,0 +1,13 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + + Tooltip content with flip enabled + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/floating.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/floating.tsx new file mode 100644 index 000000000..fc23d9c32 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/floating.tsx @@ -0,0 +1,11 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + Floating Tooltip content here + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/gutter.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/gutter.tsx new file mode 100644 index 000000000..8cebab26c --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/gutter.tsx @@ -0,0 +1,11 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + return ( + + Hover or Focus me + Tooltip content with gutter + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/hero.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/hero.tsx index 1e7853bdc..f67d12917 100644 --- a/apps/website/src/routes/docs/headless/tooltip/examples/hero.tsx +++ b/apps/website/src/routes/docs/headless/tooltip/examples/hero.tsx @@ -1,16 +1,11 @@ -import { component$, useStyles$ } from '@builder.io/qwik'; +import { component$ } from '@builder.io/qwik'; import { Tooltip } from '@qwik-ui/headless'; export default component$(() => { - useStyles$(styles); - return ( - - Hover over me - I'm a tooltip! + + Hover or Focus me + Tooltip content here ); }); - -// internal -import styles from '../snippets/tooltip.css?inline'; diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/onChange.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/onChange.tsx new file mode 100644 index 000000000..a701eb20a --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/onChange.tsx @@ -0,0 +1,19 @@ +import { component$, useSignal } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + const tooltipState = useSignal<'open' | 'closed'>('closed'); + + return ( + <> + (tooltipState.value = e)} flip> + Hover or Focus me + + + Tooltip content here + + + The tooltip is {tooltipState.value} + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/placement.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/placement.tsx new file mode 100644 index 000000000..32362498f --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/placement.tsx @@ -0,0 +1,38 @@ +import { component$, useSignal } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +export default component$(() => { + const placement = useSignal<'top' | 'right' | 'bottom' | 'left'>('top'); + + return ( +
+ + + + + Hover or Focus me + + Tooltip content on the {placement.value} + + +
+ ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/styling.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/styling.tsx new file mode 100644 index 000000000..c236753e8 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/styling.tsx @@ -0,0 +1,12 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; +import '../snippets/styling.css'; + +export default component$(() => { + return ( + + Hover or Focus me + Tooltip content here + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/examples/transition.tsx b/apps/website/src/routes/docs/headless/tooltip/examples/transition.tsx new file mode 100644 index 000000000..3f7bf3fcc --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/examples/transition.tsx @@ -0,0 +1,15 @@ +import { component$ } from '@builder.io/qwik'; +import { Tooltip } from '@qwik-ui/headless'; + +import '../snippets/transition.css'; + +export default component$(() => { + return ( + + Hover or Focus me + + Tooltip content with transition + + + ); +}); diff --git a/apps/website/src/routes/docs/headless/tooltip/index.mdx b/apps/website/src/routes/docs/headless/tooltip/index.mdx index 1b604b32d..22d654297 100644 --- a/apps/website/src/routes/docs/headless/tooltip/index.mdx +++ b/apps/website/src/routes/docs/headless/tooltip/index.mdx @@ -2,22 +2,33 @@ title: Qwik UI | Tooltip --- +import { statusByComponent } from '~/_state/component-statuses'; + +import styles from './snippets/tooltip.css'; + + + # Tooltip -A popup that shows information when an element is focused or hovered over. +A text label that appears when a user hovers, focuses, or touches an element. -## ✨ Features - +The Qwik UI Tooltip component provides additional information or context on hover, focus, or touch. It ensures accessibility and positioning with built-in ARIA roles and automatic placement adjustments. + ## Building blocks ```tsx @@ -26,15 +37,20 @@ import { Tooltip } from '@qwik-ui/headless'; export default component$(() => { return ( - - Trigger - Panel + + + + + + + Tooltip content here + ); }); ``` -### 🎨 Anatomy +### Anatomy Table { }, { name: 'Tooltip.Trigger', - description: 'A button that opens the tooltip when interacted with.', + description: 'An element that opens the tooltip when interacted with.', }, { name: 'Tooltip.Panel', - description: `An HTML Element that is above other content on the page.`, + description: `An HTML element that contains the tooltip content.`, + }, + { + name: 'Tooltip.Arrow', + description: `An optional arrow component to point to the trigger element.`, + }, + ]} +/> + +## What is a Tooltip? + +A tooltip is a small text label that appears when a user hovers over, focuses on, or touches an element. It provides additional information or context. + +### When would I use a tooltip? + +Tooltips are useful for displaying contextual information or additional details about an element without cluttering the UI. They enhance user experience by providing necessary information only when needed. + +## Use case examples + + -## API +## Caveats + + + While we handle most of the hard stuff, there are some details that should be + considered. + + +### Styling open tooltips + +```tsx +.tooltip-panel[data-open] { + background: lightblue; +} +``` + +Use the `data-open` and `data-closed` attributes on the `` component to specifically style the tooltip when it's open. + +### Cross-browser Animations + +> Animations are not currently available in the Tooltip component. We are working on a solution to provide a more seamless experience. In the meantime, you can still use transitions. + +## Tooltip Behavior + +Tooltips show when hovering over or focusing on the trigger element and dismiss when moving the mouse away or losing focus. + +## Floating Behavior + +By default, the Qwik UI Tooltip will float above the trigger component. + + + +To make a tooltip float, we use JavaScript to choose where the tooltip should be positioned. + +### Custom Floating Position + +By default, tooltips will float above the trigger component. + +When setting `placement` on the root, you can customize the position of the tooltip. + + + +Above we have set the `placement` prop to `right`, so the `` will be positioned to the right of the trigger. + +### Flip + +Enabled by default, we can use the `flip` prop to flip its position based on the available space in the viewport. + + + +To disable flipping, set `flip={false}` on the ``. + +### Gutter + +The gutter property defines the space between the anchor element and the floating element. + + + +## Styling + +Styles can be added normally like any other component in Qwik UI, such as adding a class. + +If Tailwind is the framework of choice, then styles can be added using the [arbitrary variant syntax](https://tailwindcss.com/docs/hover-focus-and-other-states#using-arbitrary-variants) or [@apply](https://tailwindcss.com/docs/reusing-styles#extracting-classes-with-apply) command. + + + +> The arbitrary variant can be customized/abstracted even further by [adding a variant](https://tailwindcss.com/docs/plugins#adding-variants) as a plugin in the tailwind config. + +### Transition declarations + +Transitions use the same classes for entry and exit animations. Those being `data-open` and `data-closed`. They are explained more in the `Caveats` section. + + + +> The [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) is another native solution that aims to solve animating between states. Support is currently in around **~71%** of browsers. + +CSS from the example: + + + +## Events + +The tooltip contains a `onOpenChange$` event that runs when the tooltip opens or closes. +This can be used to trigger additional actions when the tooltip is opened or closed. + + + +## Additional References + +Qwik UI aims to be in line with the standard whenever possible. Our goal is to empower Qwik developers to create amazing experiences for their users. + +To read more about tooltips you can check it out on: + +- [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tooltip_role) +- [WAI-ARIA Authoring Practices](https://www.w3.org/TR/wai-aria-practices-1.1/#tooltip) ### Tooltip Root { { name: 'gutter', type: 'number', - description: 'The space between the floating element and the anchored element.', + description: 'The space between the trigger element and the tooltip.', }, { - name: 'floating', - type: 'string', - description: 'The floating position of the tooltip.', + name: 'data-closing"', + type: 'selector', + description: 'Style the element when the tooltip is closing. This occurs when the popover has a delay set.', + }, + { + name: 'data-closed', + type: 'selector', + description: 'Style the element when the tooltip is closed.', + }, + + { + name: 'data-opening', + type: 'selector', + description: 'Style the element when the tooltip is in the process of opening. This occurs when the popover has a delay set.', + }, + { + name: 'data-open', + type: 'selector', + description: 'Style the element when the tooltip is open.', + }, + { + name: 'onOpenChange$', + type: 'QRL', + description: 'QRL handler that runs when the tooltip opens or closes.', + info: 'QRL<(state: "open" | "closed") => void>', + }, + +]} +/> + +### Tooltip Components + + + +### Example Usages + +#### Basic: + + + +#### Complex HTML: + + diff --git a/apps/website/src/routes/docs/headless/tooltip/snippets/animation.css b/apps/website/src/routes/docs/headless/tooltip/snippets/animation.css new file mode 100644 index 000000000..0ef140ab2 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/snippets/animation.css @@ -0,0 +1,29 @@ +.tooltip-animation { + transform: scale(0); +} + +.tooltip-animation[data-open] { + animation: tooltip-grow 0.5s ease-in-out forwards; +} + +.tooltip-animation[data-closing] { + animation: tooltip-shrink 0.4s ease-in-out forwards; +} + +@keyframes tooltip-shrink { + from { + transform: scale(1); + } + to { + transform: scale(0); + } +} + +@keyframes tooltip-grow { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} diff --git a/apps/website/src/routes/docs/headless/tooltip/snippets/arrow-styling.css b/apps/website/src/routes/docs/headless/tooltip/snippets/arrow-styling.css new file mode 100644 index 000000000..a37f1313e --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/snippets/arrow-styling.css @@ -0,0 +1,38 @@ +.tooltip-arrow-styled-panel { + background-color: #222; + color: #fff; + padding: 15px; + border-radius: 8px; + position: relative; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + transition: opacity 0.3s ease; + opacity: 0; +} + +.tooltip-arrow-styled-panel[data-open] { + opacity: 1; +} + +.tooltip-arrow-styled-arrow { + position: absolute; + width: 20px; + height: 10px; + overflow: hidden; +} + +.tooltip-arrow-styled-arrow::before { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background-color: #222; + top: -5px; + left: calc(50% - 5px); + transform: rotate(45deg); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + transition: top 0.3s ease; +} + +.tooltip-arrow-styled-panel[data-open] .tooltip-arrow-styled-arrow::before { + top: -8px; +} diff --git a/apps/website/src/routes/docs/headless/tooltip/snippets/styling.css b/apps/website/src/routes/docs/headless/tooltip/snippets/styling.css new file mode 100644 index 000000000..bca2b77f8 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/snippets/styling.css @@ -0,0 +1,14 @@ +.custom-trigger { + background-color: #007bff; + color: white; + border: none; + padding: 10px 20px; + cursor: pointer; +} + +.custom-tooltip-panel { + background-color: #333; + color: white; + padding: 10px; + border-radius: 4px; +} diff --git a/apps/website/src/routes/docs/headless/tooltip/snippets/transition.css b/apps/website/src/routes/docs/headless/tooltip/snippets/transition.css new file mode 100644 index 000000000..7c7266a35 --- /dev/null +++ b/apps/website/src/routes/docs/headless/tooltip/snippets/transition.css @@ -0,0 +1,16 @@ +.tooltip-transition { + opacity: 0; + transition: + opacity 0.5s, + display 0.5s, + overlay 0.5s; + transition-behavior: allow-discrete; +} + +.tooltip-transition[data-open] { + opacity: 1; +} + +.tooltip-transition[data-closed] { + opacity: 0; +} diff --git a/apps/website/src/routes/docs/styled/menu.md b/apps/website/src/routes/docs/styled/menu.md index dc485fdb7..260e7af50 100644 --- a/apps/website/src/routes/docs/styled/menu.md +++ b/apps/website/src/routes/docs/styled/menu.md @@ -2,7 +2,7 @@ ## Qwik UI -- [Contributing](/contributing) +- [Contributing](/docs/contributing) - [Headless](/docs/headless/introduction) - [Styled](/docs/styled/introduction) diff --git a/apps/website/vite.config.ts b/apps/website/vite.config.ts index 4c5595605..7d3229ee8 100644 --- a/apps/website/vite.config.ts +++ b/apps/website/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig(async () => { const { default: rehypePrettyCode } = await import('rehype-pretty-code'); const { visit } = await import('unist-util-visit'); - // Commented out for qwik v1.7.0 + // commented out as doesn't seem to work with import.meta.glob eager:false in preview // let output: any = {}; // if (!isDev) { // // Client-specific configuration @@ -17,12 +17,12 @@ export default defineConfig(async () => { // // Customize the client build structure // entryFileNames: ({ name }: any) => { // if (name.startsWith('entry')) { - // return '[name].js'; + // return '[name].mjs'; // } - // return `build/[name]-[hash].js`; + // return `[name]-[hash].js`; // }, // chunkFileNames: () => { - // return `build/[name]-[hash].js`; + // return `[name]-[hash].js`; // }, // assetFileNames: `build/[name]-[hash].[ext]`, // }; @@ -76,6 +76,7 @@ export default defineConfig(async () => { }, }), qwikVite({ + lint: false, tsconfigFileNames: ['tsconfig.app.json'], client: { outDir: '../../dist/apps/website/client', @@ -95,15 +96,19 @@ export default defineConfig(async () => { }, build: { target: 'es2022', - // Commented out for qwik v1.7.0 - // rollupOptions: { - // output, - // }, + rollupOptions: { + // output, + }, }, preview: { headers: { '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'], + }, }; }); diff --git a/package.json b/package.json index fa0dd558c..73cd41c13 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "packageManager": "pnpm@9.7.0", "devDependencies": { "@axe-core/playwright": "^4.9.1", - "@builder.io/qwik": "^1.7.2", - "@builder.io/qwik-city": "^1.7.2", + "@builder.io/qwik": "1.7.3", + "@builder.io/qwik-city": "1.7.3", "@changesets/cli": "^2.27.3", "@changesets/get-github-info": "^0.6.0", "@changesets/types": "^6.0.0", @@ -105,7 +105,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-cypress": "^3.2.0", "eslint-plugin-playwright": "^1.6.2", - "eslint-plugin-qwik": "^1.7.2", + "eslint-plugin-qwik": "1.7.3", "focus-trap": "7.5.4", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 37a4854de..dffb9b353 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # qwik-ui +## 0.1.3 + +### Patch Changes + +- Updated dependencies [[`519718f`](https://github.com/qwikifiers/qwik-ui/commit/519718f159b051a4858990b059dad89dc5b1ba13)]: + - @qwik-ui/utils@0.3.1 + ## 0.1.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index a4e3d4990..366f07e2b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "qwik-ui", - "version": "0.1.2", + "version": "0.1.3", "publishConfig": { "access": "public" }, @@ -13,7 +13,7 @@ "dependencies": { "@clack/prompts": "^0.7.0", "@nx/devkit": "19.2.3", - "@qwik-ui/utils": "0.3.0", + "@qwik-ui/utils": "0.3.1", "ansis": "2.3.0", "tslib": "^2.3.0", "yargs": "17.7.2" diff --git a/packages/kit-headless/CHANGELOG.md b/packages/kit-headless/CHANGELOG.md index ca8399b11..5ce627a47 100644 --- a/packages/kit-headless/CHANGELOG.md +++ b/packages/kit-headless/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.2 + +### Patch Changes + +- 🐞🩹 modal close handler onClick$ (by [@thejackshelton](https://github.com/thejackshelton) in [#909](https://github.com/qwikifiers/qwik-ui/pull/909)) + +- ✨ implement a beta version of the Tooltip component (by [@thejackshelton](https://github.com/thejackshelton) in [#934](https://github.com/qwikifiers/qwik-ui/pull/934)) + ## 0.5.1 ### Patch Changes diff --git a/packages/kit-headless/package.json b/packages/kit-headless/package.json index a030e0660..d7412c8e9 100644 --- a/packages/kit-headless/package.json +++ b/packages/kit-headless/package.json @@ -1,6 +1,6 @@ { "name": "@qwik-ui/headless", - "version": "0.5.1", + "version": "0.5.2", "description": "Qwik UI headless components library", "publishConfig": { "access": "public" diff --git a/packages/kit-headless/src/components/checkbox/checkbox.test.ts b/packages/kit-headless/src/components/checkbox/checkbox.test.ts index 8e2e311aa..75b808902 100644 --- a/packages/kit-headless/src/components/checkbox/checkbox.test.ts +++ b/packages/kit-headless/src/components/checkbox/checkbox.test.ts @@ -109,10 +109,8 @@ test.describe('checklist', () => { }); test(`GIVEN checklist with all unchecked checkboxes - WHEN all checkboxes are checked - the chekbox with aria-controls should have aria-checked true`, async ({ - page, - }) => { + WHEN all checkboxes are checked with space + the tri state checkbox should have aria-checked true`, async ({ page }) => { const exampleName = 'test-list'; const { getTriCheckbox, getCheckbox } = await setup(page, exampleName); await expect(getTriCheckbox()).toBeVisible(); @@ -122,7 +120,7 @@ test.describe('checklist', () => { }); test(`GIVEN checklist with all unchecked checkboxes - WHEN the checklist's checkbox is checked + WHEN the checklist's checkbox is checked with space THEN all chekboxes should have aria-checked true`, async ({ page }) => { const exampleName = 'test-list'; const { getTriCheckbox, getCheckbox } = await setup(page, exampleName); @@ -135,7 +133,7 @@ test.describe('checklist', () => { // TODO: reme two part of test by adding new test file test(`GIVEN checklist with all unchecked checkboxes - WHEN the checklist's checkbox is checked twice + WHEN the checklist's checkbox is checked twice using space THEN all chekboxes should go from aria-checked true to aria-checkded false`, async ({ page, }) => { @@ -278,8 +276,8 @@ test.describe('checklist', () => { }); test(`GIVEN checklist with all unchecked checkboxes - WHEN all checkboxes are checked - the chekbox with aria-controls should have aria-checked true`, async ({ + WHEN all checkboxes are checked using click + THEN the checkbox with aria-controls should have aria-checked true`, async ({ page, }) => { const exampleName = 'test-list'; @@ -291,8 +289,8 @@ test.describe('checklist', () => { }); test(`GIVEN checklist with all unchecked checkboxes - WHEN the checklist's checkbox is checked - THEN all chekboxes should have aria-checked true`, async ({ page }) => { + WHEN the checklist's checkbox is checked by clicking + THEN all checkboxes should have aria-checked true`, async ({ page }) => { const exampleName = 'test-list'; const { getTriCheckbox, getCheckbox } = await setup(page, exampleName); await expect(getTriCheckbox()).toBeVisible(); @@ -304,7 +302,7 @@ test.describe('checklist', () => { // TODO: reme two part of test by adding new test file test(`GIVEN checklist with all unchecked checkboxes - WHEN the checklist's checkbox is checked twice + WHEN the checklist's checkbox is checked twice using click THEN all chekboxes should go from aria-checked true to aria-checkded false`, async ({ page, }) => { @@ -320,23 +318,24 @@ test.describe('checklist', () => { await expect(getCheckbox().nth(1)).toHaveAttribute('aria-checked', 'false'); await expect(getCheckbox().nth(2)).toHaveAttribute('aria-checked', 'false'); }); - test(`GIVEN checklist with checkboxes - WHEN the values of aria-controls are search - IT should always return a valid, non-duplicate, checkboxes`, async ({ page }) => { - const { getTriCheckbox } = await setup(page, 'test-list'); - await expect(getTriCheckbox()).toHaveAttribute('aria-controls'); - const magic = await getTriCheckbox().getAttribute('aria-controls'); - expect(magic).not.toBe(null); - const idArr = magic!.split(' '); - expect(isUniqArr(idArr)).toBe(true); - for (let index = 0; index < idArr.length; index++) { - const elementId = idArr[index]; - const PosCheckbox = page.locator(`#${elementId}`); - await expect(PosCheckbox).toBeVisible(); - const role = await PosCheckbox.getAttribute('role'); - expect(role).toBe('checkbox'); - } - }); + + // test(`GIVEN checklist with checkboxes + // WHEN the values of aria-controls are search + // IT should always return a valid, non-duplicate, checkboxes`, async ({ page }) => { + // const { getTriCheckbox } = await setup(page, 'test-list'); + // await expect(getTriCheckbox()).toHaveAttribute('aria-controls'); + // const magic = await getTriCheckbox().getAttribute('aria-controls'); + // expect(magic).not.toBe(null); + // const idArr = magic!.split(' '); + // expect(isUniqArr(idArr)).toBe(true); + // for (let index = 0; index < idArr.length; index++) { + // const elementId = idArr[index]; + // const PosCheckbox = page.locator(`#${elementId}`); + // await expect(PosCheckbox).toBeVisible(); + // const role = await PosCheckbox.getAttribute('role'); + // expect(role).toBe('checkbox'); + // } + // }); test(`GIVEN a controlled checklist with a checklist signal of true and default checkboxes as children WHEN a child checkbox is unchecked diff --git a/packages/kit-headless/src/components/popover/popover-root.tsx b/packages/kit-headless/src/components/popover/popover-root.tsx index 1c71b3bf9..12a86d0f5 100644 --- a/packages/kit-headless/src/components/popover/popover-root.tsx +++ b/packages/kit-headless/src/components/popover/popover-root.tsx @@ -14,6 +14,7 @@ export type PopoverRootProps = { manual?: boolean; ref?: Signal; floating?: boolean | TPlacement; + /** @deprecated Use the tooltip instead, which adheres to the WAI-ARIA design pattern. */ hover?: boolean; id?: string; 'bind:anchor'?: Signal; diff --git a/packages/kit-headless/src/components/popover/popover.test.ts b/packages/kit-headless/src/components/popover/popover.test.ts index e561b1b9b..ca1248daa 100644 --- a/packages/kit-headless/src/components/popover/popover.test.ts +++ b/packages/kit-headless/src/components/popover/popover.test.ts @@ -148,52 +148,15 @@ test.describe('Mouse Behavior', () => { await expect(secondPopover).toBeHidden(); }); - test(`GIVEN a popover with hover enabled - WHEN hovering over the popover - THEN the popover should appear above the trigger`, async ({ page }) => { - const { driver: d } = await setup(page, 'hover'); - - const popover = d.getPopover(); - const trigger = d.getTrigger(); - - await trigger.hover(); - - await expect(popover).toBeVisible(); - - const popoverBoundingBox = await popover.boundingBox(); - const triggerBoundingBox = await trigger.boundingBox(); - - const triggerTopEdge = triggerBoundingBox?.y ?? Number.MAX_VALUE; - - expect(popoverBoundingBox?.y).toBeLessThan(triggerTopEdge); - }); - - test(`GIVEN an open popover with hover enabled - WHEN hovering await from the popover - THEN the popover should disappear`, async ({ page }) => { - const { driver: d } = await setup(page, 'hover'); - - const popover = d.getPopover(); - const trigger = d.getTrigger(); - - await trigger.hover(); - - await expect(popover).toBeVisible(); - - await page.mouse.move(0, 0); - - await expect(popover).toBeHidden(); - }); - test(`GIVEN a popover with placement set to right - WHEN hovering over the popover + WHEN clicking the popover THEN the popover should appear to the right of the trigger`, async ({ page }) => { const { driver: d } = await setup(page, 'placement'); const popover = d.getPopover(); const trigger = d.getTrigger(); - await trigger.hover(); + await trigger.click(); await expect(popover).toBeVisible(); @@ -456,8 +419,8 @@ test.describe('Keyboard Behavior', () => { }); test(`GIVEN an open programmatic popover - WHEN the hidePopover function is called - THEN the popover should be hidden`, async ({ page }) => { + WHEN the hidePopover function is called + THEN the popover should be hidden`, async ({ page }) => { const { driver: d } = await setup(page, 'test-hide'); // Initial open diff --git a/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/closed-popover-popover-chrome-108-darwin.png b/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/closed-popover-popover-chrome-108-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..51e8c3f3a9e14f83525f5706445b6c87f3fed1f5 GIT binary patch literal 8305 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 JKR)v9{{Uz$jeP(B literal 0 HcmV?d00001 diff --git a/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/opened-popover-popover-chrome-108-darwin.png b/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/opened-popover-popover-chrome-108-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..51e8c3f3a9e14f83525f5706445b6c87f3fed1f5 GIT binary patch literal 8305 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 JKR)v9{{Uz$jeP(B literal 0 HcmV?d00001 diff --git a/packages/kit-headless/src/components/tooltip/index.ts b/packages/kit-headless/src/components/tooltip/index.ts index 9f7759a87..2f0dbf448 100644 --- a/packages/kit-headless/src/components/tooltip/index.ts +++ b/packages/kit-headless/src/components/tooltip/index.ts @@ -1,3 +1,4 @@ export { HTooltipRoot as Root } from './tooltip-root'; -export { HTooltipContent as Content } from './tooltip-content'; +export { HTooltipPanel as Panel } from './tooltip-panel'; export { HTooltipTrigger as Trigger } from './tooltip-trigger'; +export { HTooltipArrow as Arrow } from './tooltip-arrow'; diff --git a/packages/kit-headless/src/components/tooltip/tooltip-arrow.tsx b/packages/kit-headless/src/components/tooltip/tooltip-arrow.tsx new file mode 100644 index 000000000..0fa9f1e12 --- /dev/null +++ b/packages/kit-headless/src/components/tooltip/tooltip-arrow.tsx @@ -0,0 +1,38 @@ +import { component$ } from '@builder.io/qwik'; + +/** + * TooltipArrowProps defines the properties for the Tooltip Arrow component. + */ +export type TooltipArrowProps = { + /** + * The width of the arrow. + */ + width?: number; + + /** + * The height of the arrow. + */ + height?: number; + + /** + * Additional class names for styling. + */ + class?: string; +}; + +/** + * HTooltipArrow is the arrow component for the Tooltip. + */ +export const HTooltipArrow = component$((props: TooltipArrowProps) => { + const { width = 10, height = 5, class: className } = props; + + return ( +
+ ); +}); diff --git a/packages/kit-headless/src/components/tooltip/tooltip-content.tsx b/packages/kit-headless/src/components/tooltip/tooltip-content.tsx deleted file mode 100644 index 3f78a47d9..000000000 --- a/packages/kit-headless/src/components/tooltip/tooltip-content.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { PropsOf, Slot, component$ } from '@builder.io/qwik'; -import { HPopoverPanel } from '../popover/popover-panel'; - -export const HTooltipContent = component$((props: PropsOf) => { - return ( - - - - ); -}); diff --git a/packages/kit-headless/src/components/tooltip/tooltip-context.ts b/packages/kit-headless/src/components/tooltip/tooltip-context.ts new file mode 100644 index 000000000..fcef105ab --- /dev/null +++ b/packages/kit-headless/src/components/tooltip/tooltip-context.ts @@ -0,0 +1,18 @@ +import { createContextId, QRL, Signal } from '@builder.io/qwik'; + +export const TooltipContextId = createContextId('Tooltip'); + +export type TooltipContext = { + compId: string; + localId: string; + + delayDuration: number; + + triggerRef: Signal; + + state: Signal; + + onOpenChange$: QRL<(state: 'open' | 'closed') => void>; +}; + +export type TriggerDataState = 'closing' | 'closed' | 'opening' | 'open'; diff --git a/packages/kit-headless/src/components/tooltip/tooltip-panel.tsx b/packages/kit-headless/src/components/tooltip/tooltip-panel.tsx new file mode 100644 index 000000000..898e02c8c --- /dev/null +++ b/packages/kit-headless/src/components/tooltip/tooltip-panel.tsx @@ -0,0 +1,23 @@ +import { PropsOf, Slot, component$, useContext } from '@builder.io/qwik'; +import { HPopoverPanel } from '../popover/popover-panel'; +import { TooltipContextId } from './tooltip-context'; + +export type HTooltipPanelProps = PropsOf; + +/** + * HTooltipPanel is the panel component for the Tooltip. + */ +export const HTooltipPanel = component$((props: HTooltipPanelProps) => { + const context = useContext(TooltipContextId); + + return ( + context.onOpenChange$(e.newState)} + id={context.localId} + > + + + ); +}); diff --git a/packages/kit-headless/src/components/tooltip/tooltip-root.tsx b/packages/kit-headless/src/components/tooltip/tooltip-root.tsx index f8ae7a685..c3b74809b 100644 --- a/packages/kit-headless/src/components/tooltip/tooltip-root.tsx +++ b/packages/kit-headless/src/components/tooltip/tooltip-root.tsx @@ -1,12 +1,98 @@ -import { PropsOf, Slot, component$ } from '@builder.io/qwik'; -import { HPopoverRoot } from '../popover/popover-root'; +import { + PropsOf, + QRL, + Signal, + Slot, + component$, + useContextProvider, + useId, + useSignal, + $, +} from '@builder.io/qwik'; +import { FloatingProps, HPopoverRoot } from '../popover/popover-root'; +import { TooltipContext, TooltipContextId, TriggerDataState } from './tooltip-context'; -export const HTooltipRoot = component$((props: PropsOf) => { - const { hover = true, floating = 'top', ...rest } = props; +/** + * TooltipRootProps defines the properties for the Tooltip Root component. + */ +export type TooltipRootProps = { + /** + * A value that determines whether the tooltip is open. + */ + open?: boolean; + + /** A signal that controls the current open state (controlled). */ + 'bind:open'?: Signal; + + /** + * QRL handler that runs when the tooltip opens or closes. + * @param open The new state of the tooltip. + */ + onOpenChange$?: QRL<(state: 'open' | 'closed') => void>; + + /** + * A value that determines how long before the tooltip will + * be opened once triggered in milliseconds. + */ + delayDuration?: number; + + /** + * The default position of the tooltip. + */ + placement?: Parameters['0']['floating']; + + id?: string; +} & Pick; + +/** + * TooltipProps combines TooltipRootProps and the properties of a div element. + */ +export type TooltipProps = TooltipRootProps & Exclude, 'ref'>; + +/** + * HTooltipRoot is the root component for the Tooltip. + */ +export const HTooltipRoot = component$((props: TooltipProps) => { + const { + placement = 'top', + id, + gutter, + delayDuration = 0, + flip, + onOpenChange$, + ...rest + } = props; + + const triggerRef = useSignal(); + const tooltipState = useSignal('closed'); + + const localId = useId(); + const compId = id ?? localId; + + const context: TooltipContext = { + compId, + localId, + triggerRef, + delayDuration, + state: tooltipState, + onOpenChange$: $((e) => onOpenChange$?.(e)), + }; + + useContextProvider(TooltipContextId, context); return ( - - + +
+ +
); }); diff --git a/packages/kit-headless/src/components/tooltip/tooltip-trigger.tsx b/packages/kit-headless/src/components/tooltip/tooltip-trigger.tsx index e6521a6ca..7577417a9 100644 --- a/packages/kit-headless/src/components/tooltip/tooltip-trigger.tsx +++ b/packages/kit-headless/src/components/tooltip/tooltip-trigger.tsx @@ -1,10 +1,107 @@ -import { PropsOf, Slot, component$ } from '@builder.io/qwik'; -import { HPopoverTrigger } from '../popover/popover-trigger'; +import { + Slot, + component$, + sync$, + useContext, + $, + PropsOf, + useSignal, + Signal, + useTask$, +} from '@builder.io/qwik'; +import { TooltipContextId, TriggerDataState } from './tooltip-context'; +import { isServer } from '@builder.io/qwik/build'; +import { usePopover } from '../popover/use-popover'; + +/** + * HTooltipTrigger is the trigger component for the Tooltip. + */ +export const HTooltipTrigger = component$((props: PropsOf<'button'>) => { + const context = useContext(TooltipContextId); + + const openTimeout = useSignal(); + const closeTimeout = useSignal(); + + const { showPopover, hidePopover } = usePopover(context.localId); + + const clearTimeoutIfExists = $((timeoutRef: Signal) => { + if (timeoutRef.value) { + window.clearTimeout(timeoutRef.value); + timeoutRef.value = undefined; + } + }); + + const setTooltipState = $( + (open: boolean, state: TriggerDataState, timeoutRef: Signal) => { + context.state.value = state; + + if (context.delayDuration > 0) { + timeoutRef.value = window.setTimeout(() => { + context.state.value = open ? 'open' : 'closed'; + }, context.delayDuration); + } else { + context.state.value = open ? 'open' : 'closed'; + } + }, + ); + + const setTooltipOpen$ = $(() => { + clearTimeoutIfExists(closeTimeout); + showPopover(); + setTooltipState(true, 'opening', openTimeout); + }); + + const setTooltipClosed$ = $(() => { + clearTimeoutIfExists(openTimeout); + hidePopover(); + setTooltipState(false, 'closing', closeTimeout); + }); + + const preventDefaultSync$ = sync$((e: Event) => { + e.preventDefault(); + }); + + const handleKeyDown$ = $(async (e: KeyboardEvent) => { + if (context.state.value === 'open' && e.key === 'Escape') { + e.preventDefault(); + setTooltipClosed$(); + } + }); + + useTask$(({ track, cleanup }) => { + track(() => context.state.value); + + if (isServer) return; + + if (context.state.value === 'open') { + document.addEventListener('keydown', handleKeyDown$); + + cleanup(() => { + document.removeEventListener('keydown', handleKeyDown$); + }); + } else if (context.state.value === 'closed') { + document.removeEventListener('keydown', handleKeyDown$); + } + + // Cleanup function to ensure the event listener is removed + cleanup(() => { + document.removeEventListener('keydown', handleKeyDown$); + }); + }); -export const HTooltipTrigger = component$((props: PropsOf) => { return ( - + ); }); diff --git a/packages/kit-headless/src/components/tooltip/tooltip.driver.ts b/packages/kit-headless/src/components/tooltip/tooltip.driver.ts new file mode 100644 index 000000000..3012f7f0c --- /dev/null +++ b/packages/kit-headless/src/components/tooltip/tooltip.driver.ts @@ -0,0 +1,105 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export type DriverLocator = Locator | Page; + +export type TooltipOpenKeys = 'Enter' | 'Space'; + +export function createTooltipDriver(rootLocator: T) { + const getTooltip = () => { + return rootLocator.locator('[role="tooltip"]'); + }; + + const getTooltipByTextContent = (tooltipContent: string) => { + return rootLocator.locator('.tooltip-panel').getByText(tooltipContent); + }; + + const getTrigger = () => { + return rootLocator.locator('[aria-describedby]'); + }; + + const openTooltip = async ( + key: TooltipOpenKeys | 'hover' | 'click', + index?: number, + ) => { + const action = key === 'click' ? 'click' : key === 'hover' ? 'hover' : 'press'; + const trigger = index !== undefined ? getTrigger().nth(index) : getTrigger(); + + const tooltip = + index !== undefined + ? getTooltipByTextContent(`Tooltip ${index + 1}`) + : getTooltip(); + + if (action === 'click') { + await trigger.click({ position: { x: 1, y: 1 } }); + } else if (action === 'hover') { + await trigger.hover(); + } else { + await trigger.press(key); + } + + // Needed because Playwright doesn't wait for the tooltip to be visible + await expect(tooltip).toBeVisible(); + + return { trigger, tooltip }; + }; + + const getAllTooltips = () => { + return getTooltip().all(); + }; + + const getAllTriggers = () => { + return getTrigger().all(); + }; + + const getOnChangeVerificationText = (state: 'open' | 'closed') => { + return rootLocator.getByText(`The tooltip is ${state}`); + }; + + const getProgrammaticButtonTrigger = () => { + return rootLocator.locator('button'); + }; + + const selectOption = async (placement: string) => { + const dropdown = rootLocator.getByLabel('Select Tooltip Placement:'); + await dropdown.selectOption(placement); + }; + + const validateTooltipPosition = async (placement: string) => { + const triggerBoundingBox = await getTrigger().boundingBox(); + const tooltipBoundingBox = await getTooltip().boundingBox(); + + switch (placement) { + case 'top': + expect(tooltipBoundingBox?.y).toBeLessThan(triggerBoundingBox?.y ?? 0); + break; + case 'right': + expect(tooltipBoundingBox?.x).toBeGreaterThan( + (triggerBoundingBox?.x ?? 0) + (triggerBoundingBox?.width ?? 0), + ); + break; + case 'bottom': + expect(tooltipBoundingBox?.y).toBeGreaterThan( + (triggerBoundingBox?.y ?? 0) + (triggerBoundingBox?.height ?? 0), + ); + break; + case 'left': + expect(tooltipBoundingBox?.x).toBeLessThan(triggerBoundingBox?.x ?? 0); + break; + } + }; + + return { + ...rootLocator, + locator: rootLocator, + getTooltip, + getAllTooltips, + getTrigger, + getAllTriggers, + openTooltip, + getProgrammaticButtonTrigger, + getTooltipByTextContent, + getOnChangeVerificationText, + selectOption, + validateTooltipPosition, + }; +} diff --git a/packages/kit-headless/src/components/tooltip/tooltip.test.ts b/packages/kit-headless/src/components/tooltip/tooltip.test.ts new file mode 100644 index 000000000..13baa4053 --- /dev/null +++ b/packages/kit-headless/src/components/tooltip/tooltip.test.ts @@ -0,0 +1,266 @@ +import { expect, test, type Page } from '@playwright/test'; +import { createTooltipDriver } from './tooltip.driver'; +import { assertBoundingBoxExists } from '../../utils/test-utils'; + +async function setup(page: Page, exampleName: string) { + await page.goto(`/headless/tooltip/${exampleName}`); + + const driver = createTooltipDriver(page); + + return { + driver, + }; +} + +test('@Visual diff', async ({ page }) => { + const { driver: d } = await setup(page, 'basic'); + await expect(page).toHaveScreenshot('closed tooltip.png'); + + await d.getTrigger().hover(); + + await expect(page).toHaveScreenshot('opened tooltip.png'); +}); + +test.describe('Mouse Behavior', () => { + test(`GIVEN a closed tooltip + WHEN hovering over the trigger + THEN the tooltip should open`, async ({ page }) => { + const { driver: d } = await setup(page, 'basic'); + await expect(d.getTooltip()).toBeHidden(); + + await d.getTrigger().hover(); + + await expect(d.getTooltip()).toBeVisible(); + }); + + test(`GIVEN an open tooltip + WHEN moving the mouse away from the trigger + THEN the tooltip should close`, async ({ page }) => { + const { driver: d } = await setup(page, 'basic'); + + await d.getTrigger().hover(); + await expect(d.getTooltip()).toBeVisible(); + + await page.mouse.move(0, 0); + await expect(d.getTooltip()).toBeHidden(); + }); + + test(`GIVEN an open tooltip + WHEN clicking on the trigger + THEN the tooltip should remain open`, async ({ page }) => { + const { driver: d } = await setup(page, 'basic'); + + await d.getTrigger().hover(); + await expect(d.getTooltip()).toBeVisible(); + + await d.getTrigger().click(); + await expect(d.getTooltip()).toBeVisible(); + }); +}); + +test.describe('Keyboard Behavior', () => { + test(`GIVEN an open tooltip + WHEN focusing on the trigger and pressing the 'Escape' key + THEN the tooltip should close`, async ({ page }) => { + const { driver: d } = await setup(page, 'basic'); + await expect(d.getTooltip()).toBeHidden(); + + await d.getTrigger().focus(); + await expect(d.getTooltip()).toBeVisible(); + + await d.getTrigger().press('Escape'); + await expect(d.getTooltip()).not.toBeVisible(); + }); +}); + +test.describe('Placement and Positioning', () => { + test(`GIVEN a tooltip with placement set to top + WHEN hovering over the trigger + THEN the tooltip should appear at the top of the trigger`, async ({ page }) => { + const { driver: d } = await setup(page, 'placement'); + + const placement = 'top'; + d.selectOption(placement); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + + await expect(tooltip).toBeVisible(); + + d.validateTooltipPosition(placement); + }); + + test(`GIVEN a tooltip with placement set to right + WHEN hovering over the trigger + THEN the tooltip should appear at the right of the trigger`, async ({ page }) => { + const { driver: d } = await setup(page, 'placement'); + + const placement = 'right'; + d.selectOption(placement); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + + await expect(tooltip).toBeVisible(); + + d.validateTooltipPosition(placement); + }); + + test(`GIVEN a tooltip with placement set to bottom + WHEN hovering over the trigger + THEN the tooltip should appear at the bottom of the trigger`, async ({ page }) => { + const { driver: d } = await setup(page, 'placement'); + + const placement = 'bottom'; + d.selectOption(placement); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + + await expect(tooltip).toBeVisible(); + + d.validateTooltipPosition(placement); + }); + + test(`GIVEN a tooltip with placement set to left + WHEN hovering over the trigger + THEN the tooltip should appear at the left of the trigger`, async ({ page }) => { + const { driver: d } = await setup(page, 'placement'); + + const placement = 'left'; + d.selectOption(placement); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + + await expect(tooltip).toBeVisible(); + + d.validateTooltipPosition(placement); + }); + + test(`GIVEN a tooltip with a gutter configured + WHEN opening the tooltip + THEN the tooltip should be spaced correctly from the trigger`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'gutter'); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + + const tooltipBoundingBox = await tooltip.boundingBox(); + const triggerBoundingBox = await trigger.boundingBox(); + + assertBoundingBoxExists(tooltipBoundingBox); + assertBoundingBoxExists(triggerBoundingBox); + + const gutterSpace = triggerBoundingBox.y - tooltipBoundingBox.y; + expect(gutterSpace).toBe(44); + }); + + test(`GIVEN a tooltip with flip configured + WHEN scrolling the page + THEN the tooltip should flip to the opposite end once space runs out`, async ({ + page, + }) => { + const { driver: d } = await setup(page, 'flip'); + + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + async function calculateYDiff() { + const tooltipBoundingBox = await tooltip.boundingBox(); + const triggerBoundingBox = await trigger.boundingBox(); + + assertBoundingBoxExists(tooltipBoundingBox); + assertBoundingBoxExists(triggerBoundingBox); + + return tooltipBoundingBox.y - triggerBoundingBox.y; + } + + // Introduce artificial spacing + await trigger.evaluate((element) => (element.style.marginTop = '2000px')); + await trigger.evaluate((element) => (element.style.marginBottom = '1000px')); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + + let yDiff = await calculateYDiff(); + expect(yDiff).toBeLessThan(0); + + await page.evaluate(() => window.scrollBy(0, 340)); + + await page.waitForTimeout(1000); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + + yDiff = await calculateYDiff(); + expect(yDiff).toBeGreaterThan(0); + }); +}); + +test.describe('Tooltip Animations', () => { + test(`GIVEN a tooltip with an animation + WHEN hovering over the trigger + THEN the tooltip should open with an animation`, async ({ page }) => { + const { driver: d } = await setup(page, 'animation'); + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + await expect(tooltip).toBeHidden(); + + // Wait for the duration of the animation (e.g., 500ms) + await page.waitForTimeout(500); + + await expect(tooltip).toBeVisible(); + }); + + test(`GIVEN an open tooltip with an animation + WHEN moving the mouse away from the trigger + THEN the tooltip should close with an animation`, async ({ page }) => { + const { driver: d } = await setup(page, 'animation'); + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + await trigger.hover(); + await page.waitForTimeout(500); + await expect(tooltip).toBeVisible(); + + await page.mouse.move(0, 0); + + // Wait for the duration of the animation (e.g., 500ms) + await page.waitForTimeout(500); + + await expect(tooltip).toBeHidden(); + }); +}); + +test.describe('Tooltip Events', () => { + test(`GIVEN a tooltip with opOpenChange configured + WHEN hovering over the trigger + THEN the text should say "The tooltip is open"`, async ({ page }) => { + const { driver: d } = await setup(page, 'onChange'); + const tooltip = d.getTooltip(); + const trigger = d.getTrigger(); + + expect(d.getOnChangeVerificationText('closed')).toBeVisible(); + + await trigger.hover(); + await expect(tooltip).toBeVisible(); + + expect(d.getOnChangeVerificationText('open')).toBeVisible(); + }); +}); diff --git a/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/closed-tooltip-popover-chrome-108-darwin.png b/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/closed-tooltip-popover-chrome-108-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..e006f20774fad6d7fdbb8ca14a972bf811f31ca3 GIT binary patch literal 8123 zcmeHM{a@189{=_$`D>I|777DcMO$$?IiueTdu-R>; zuGzFybj_yuRX{UPQN*6K&4PRY@&VD%P*BM!U=9R=(}@HUvQ{_wW1q2m~zykBgr#e+vW&T@ePtqQoOXdmxV3WekGcsQbVE z>S#*QxPo4lPqnm78GZWywk_WGhX)=yg!W=^Tq4wSSv zDQA={I#+8VmPm}g^L|DlCj=J*UW>qdplb!BF?;j@@^oGFE*dY`pF_jD2ce}PoIA02 z1KM2b2%-Hq+<=y&&HGDtqha|bXfX)8x7mi;w#k+fHs;t=!lsBewFr{!;?t?JmX|P0BSm5l>CU)ZWwO(vf_UwTOPv&VjKkdMW4}3=W_v zt4GJjtIEpC@=sQIiFcIdZ6%~V71KU@W&OLjqbr6e)-H!$D<5oWfgrzn<3M`spVv1_ zyStC7TC*x2Us)3#6chx*mO(r0otmgNS+?s=0X=%6mxoCne~~tPEeJt0?$|f`RW7eu`S6-9lW)=~ zR?5rER5gnxQ_fqB1z()}MzyOloI7dgndqyN7x^TTpZxR@Ci>|OfuT26J9z)1r^#s% zWcGd!oZX8@)Y$n{Ldwt&Jg_{xAn7!VVH)FQy!orZCo$U3GSj7>q=_1P`}$(L%7PXC zwDv7^-M@Zykv#n58jq@#2?j5$AgjeJszooCSAYAEeE-5Sy`IYyBFHK?|Hz4n3H=`- zjJxYo2G5&j+pxqK7Bwtq`Z)j0aUP#9YdOC}o9P^X=aZo9?xxgfCpCifHYCQdi9$hG z)v-`q*};mju5!_+PFXmW=Sd^RU(MUs^?y7borL2o3Rn^AFy=d)@0|ib9P_ zCUd3Bj_S4stZB3qb9VSi!S*=3f-yG%>z~%XgPpzaAXCtkggL`Rg%{e6^UY>@@At@CoRClqG`he zcsY4RAB5q!{`#6^-2kD))LmZeWo&a}HsSGjvUzrjI&U$C1FW_8?cAyoRISpUm=XfO zgQN#}G9^0JoRLbmPM2^)n5Hg0k4tBR#MQ=8(j#D)O%gS~60;&ivyVN=FCSf&qzfVF z{n=$3pw|T*brhx3wi7LhM37Y!m&C!A^J$~EE|CFTS+n6V$}=Zzx#Oc=L=#v+@9Q8ZvEgphWrS3&dsn~yRoXDzd zzq-y#a|vr6y0Tt(*}eD>}aMUBLYJrT8*N`G%Nu=D9K6XH#SJyO>ilxnv z9}^bcC5af4baQ}%-%%LWrO6wMYjr)6gr(5F;Wg(VC@8h7Z~%PbfHi+tH{t5)N(J!l zt&cr@x}tq+V^w_P1ix39PK#3rc+ujb<1k#hHw!_KjR*3&qjLNVMe^&LacO=foYyj5 z^}c+LQL>6UPXZ$DVfLGI(hv8nNZXY@loiVr@&Q4aQcvcab`&|wXb={e25J&05xJMM zlJQw?*ygtR#|T`RJ$4L&xO>6fKuhSr>rC(NJE5gy;7y7Tt49TojT01RdQ{)jn_jV6 z89Pf#@oX6W89|)#7i3MjE_P&jgd#<64hch5EFT6jz8Xc` zvGYF+kk#@}w4nwQ9+?oxj;P~s>RI;Oh1zXbrV<& zeX)9t6znF`YDRH|VKfCM>s8qWSSACwaTqLJ1H{n;{Jw8ijBZ2F`qbNDoXDQp)g#N+ zL>>mNKuar%O=x_vkYW*R-v#T(sn^ra`hu*a2K?-mJ!IXH_}#OqTjswj8{ZZWyls zP+}>^%s^j5A7-wslnW=Rev?>`xD!3K<-iRD>?h_UJ5pYsaP>{P1KPw+V7tWN|J*i3 zzqfE{X}gHD-PhXgrfv7|wsM87G-In^*{XH6%H;oW0oPjYy7MQrn!I+HLeJrDTYTc+ zLK(T`#=lF>pD$XdF-I+4ING%?-2l2mSM7JBVXfmiG;G_nP>%jD27Y)=*K;$lABz^{ Oq5Xk}zUJ&Xap7OGurW^n literal 0 HcmV?d00001 diff --git a/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/opened-tooltip-popover-chrome-108-darwin.png b/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/opened-tooltip-popover-chrome-108-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..8f3a2566a7cef3617c3543ae1a7312f5d700d422 GIT binary patch literal 10875 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 XTdaUPCHMEC^$7<(4wU?G=3oB=16.0.0" + }, + "private": false, + "scripts": {}, + "peerDependencies": { + "@builder.io/qwik": "1.7.2" + }, + "devDependencies": {} +} diff --git a/packages/themes/project.json b/packages/themes/project.json new file mode 100644 index 000000000..4ef115b3c --- /dev/null +++ b/packages/themes/project.json @@ -0,0 +1,25 @@ +{ + "name": "themes", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/themes/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "main": "src/index.ts", + "outputPath": "dist/packages/themes", + "tsConfig": "packages/themes/tsconfig.lib.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/themes/**/*.{ts,tsx,js,jsx}"] + } + } + }, + "tags": [] +} diff --git a/packages/themes/src/index.ts b/packages/themes/src/index.ts new file mode 100644 index 000000000..ac3728741 --- /dev/null +++ b/packages/themes/src/index.ts @@ -0,0 +1,2 @@ +export type { Theme } from './lib/types'; +export { useTheme, ThemeProvider } from './lib/provider'; diff --git a/packages/themes/src/lib/provider.tsx b/packages/themes/src/lib/provider.tsx new file mode 100644 index 000000000..53acfda78 --- /dev/null +++ b/packages/themes/src/lib/provider.tsx @@ -0,0 +1,195 @@ +import { + $, + Fragment, + Slot, + component$, + createContextId, + useContext, + useContextProvider, + useOnWindow, + useSignal, + useTask$, + useVisibleTask$, +} from '@builder.io/qwik'; +import { ThemeScript } from './theme-script'; +import type { SystemTheme, Theme, ThemeProviderProps, UseThemeProps } from './types'; +import { isServer } from '@builder.io/qwik/build'; + +const ThemeContext = createContextId('theme-context'); + +export const useTheme = () => useContext(ThemeContext); + +const defaultThemes = ['light', 'dark']; + +export const ThemeProvider = component$( + ({ + forcedTheme, + disableTransitionOnChange = false, + enableSystem = true, + enableColorScheme = true, + storageKey = 'theme', + themes = defaultThemes, + defaultTheme = enableSystem ? 'system' : 'light', + attribute = 'data-theme', + value, + nonce, + }) => { + const themeSig = useSignal(''); + const resolvedThemeSig = useSignal(storageKey); + + const attrs = !value ? themes.flat() : Object.values(value); + + const applyTheme = $(async (theme: Theme) => { + let resolved = theme; + if (!resolved) return; + + // If theme is system, resolve it before setting theme + if (theme === 'system' && enableSystem) { + resolved = await getSystemTheme(); + } + + // Join the array of attr if the theme is an array + const computedResolved = Array.isArray(resolved) + ? resolved.join(attribute === 'class' ? ' ' : '-') + : resolved; + + const name = value ? value[computedResolved] : computedResolved; + + disableTransitionOnChange ? disableAnimation() : null; + const d = document.documentElement; + + if (attribute === 'class') { + d.classList.remove(...attrs); + + if (name) d.classList.add(...name.split(' ')); + } else { + if (name) { + d.setAttribute(attribute, name); + } else { + d.removeAttribute(attribute); + } + } + }); + + // eslint-disable-next-line qwik/no-use-visible-task -- not possible atm to useOnWindow for a MediaQueryList event + useVisibleTask$( + ({ cleanup }) => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleMediaQuery = $(async (e: MediaQueryListEvent | MediaQueryList) => { + const resolved = await getSystemTheme(e); + resolvedThemeSig.value = resolved; + + if (themeSig.value === 'system' && enableSystem && !forcedTheme) { + applyTheme('system'); + } + }); + + media.addEventListener('change', handleMediaQuery); + + handleMediaQuery(media); + + cleanup(() => media.removeEventListener('change', handleMediaQuery)); + }, + { strategy: 'document-idle' }, + ); + + // localStorage event handling + + useOnWindow( + 'storage', + $((e: StorageEvent) => { + if (e.key !== storageKey) { + return; + } + // If default theme set, use it if localStorage === null (happens on local storage manual deletion) + const theme = e.newValue || defaultTheme; + themeSig.value = theme; + }), + ); + + useTask$(({ track }) => { + track(() => themeSig.value); + if (isServer) return; + localStorage.setItem( + storageKey, + Array.isArray(themeSig.value) ? themeSig.value.join(' ') : themeSig.value || '', + ); + }); + + // Whenever theme or forcedTheme changes, apply it + useTask$(({ track }) => { + track(() => themeSig.value || forcedTheme); + + if (themeSig.value !== 'system') { + resolvedThemeSig.value = themeSig.value; + } + + applyTheme(forcedTheme ?? themeSig.value); + }); + + useContextProvider(ThemeContext, { + defaultTheme, + themeSig, + resolvedThemeSig, + forcedTheme, + storageKey, + systemTheme: (enableSystem ? resolvedThemeSig.value : undefined) as + | SystemTheme + | undefined, + themes: enableSystem + ? Array.isArray(themes[0]) + ? [...(themes as string[][]), ['system']] + : [...(themes as string[]), 'system'] + : themes, + }); + + return ( + + + + + ); + }, +); + +const getSystemTheme = $((mq?: MediaQueryList | MediaQueryListEvent): SystemTheme => { + const currMq = mq || window.matchMedia('(prefers-color-scheme: dark)'); + const isDark = currMq.matches; + const systemTheme = isDark ? 'dark' : 'light'; + return systemTheme; +}); + +const disableAnimation = () => { + const css = document.createElement('style'); + css.appendChild( + document.createTextNode( + '*{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}', + ), + ); + document.head.appendChild(css); + + return () => { + // Force restyle + (() => window.getComputedStyle(document.body))(); + + // Wait for next tick before removing + setTimeout(() => { + document.head.removeChild(css); + }, 1); + }; +}; diff --git a/packages/themes/src/lib/theme-script.tsx b/packages/themes/src/lib/theme-script.tsx new file mode 100644 index 000000000..bcc40533a --- /dev/null +++ b/packages/themes/src/lib/theme-script.tsx @@ -0,0 +1,98 @@ +import type { ThemeProviderProps } from './types'; + +const colorSchemes = ['light', 'dark']; + +export const ThemeScript = ({ + forcedTheme, + storageKey, + attribute, + enableSystem, + enableColorScheme, + defaultTheme, + value, + attrs, + nonce, +}: ThemeProviderProps & { attrs: string[]; defaultTheme: string }) => { + const defaultSystem = defaultTheme === 'system'; + + // Code-golfing the amount of characters in the script + const optimization = (() => { + if (attribute === 'class') { + const removeClasses = `c.remove(${attrs.map((t: string) => `'${t}'`).join(',')})`; + + return `var d=document.documentElement,c=d.classList;${removeClasses};`; + } + + return `var d=document.documentElement,n='${attribute}',s='setAttribute';`; + })(); + + const fallbackColorScheme = (() => { + if (!enableColorScheme) { + return ''; + } + + const fallback = colorSchemes.includes(defaultTheme) ? defaultTheme : null; + + if (fallback) { + return `if(e==='light'||e==='dark'||!e)d.style.colorScheme=e||'${defaultTheme}'`; + } + + return `if(e==='light'||e==='dark')d.style.colorScheme=e`; + })(); + + const updateDOM = (name: string, literal = false, setColorScheme = true) => { + const resolvedName = value ? value[name] : name; + const val = literal ? `${name} || ''` : `'${resolvedName}'`; + let text = ''; + + // MUCH faster to set colorScheme alongside HTML attribute/class + // as it only incurs 1 style recalculation rather than 2 + // This can save over 250ms of work for pages with big DOM + if (enableColorScheme && setColorScheme && !literal && colorSchemes.includes(name)) { + text += `d.style.colorScheme = '${name}';`; + } + + if (attribute === 'class') { + if (literal || resolvedName) { + text += `(${val}).split(' ').forEach(v => c.add(v))`; + } else { + text += 'null'; + } + } else { + if (resolvedName) { + text += `d[s](n,${val})`; + } + } + + return text; + }; + + const scriptSrc = (() => { + if (forcedTheme) { + return `!function(){${optimization}${updateDOM(forcedTheme)}}()`; + } + + if (enableSystem) { + return `!function(){try{${optimization}var e=localStorage.getItem('${storageKey}');if('system'===e||(!e&&${defaultSystem})){var t='(prefers-color-scheme: dark)',m=window.matchMedia(t);if(m.media!==t||m.matches){${updateDOM( + 'dark', + )}}else{${updateDOM('light')}}}else if(e){${value ? `var x=${JSON.stringify(value)};` : ''}${updateDOM( + value ? 'x[e]' : 'e', + true, + )}}${ + !defaultSystem ? `else{${updateDOM(defaultTheme, false, false)}}` : '' + }${fallbackColorScheme}}catch(e){}}()`; + } + + return `!function(){ + try{${optimization}var e=localStorage.getItem('${storageKey}');if(e){${ + value ? `var x=${JSON.stringify(value)};` : '' + }${updateDOM(value ? 'x[e]' : 'e', true)}}else{${updateDOM( + defaultTheme, + false, + false, + )};}${fallbackColorScheme}}catch(t){}}();`; + })(); + + // biome-ignore lint/security/noDangerouslySetInnerHtml: + return