diff --git a/.changeset/tasty-pugs-decide.md b/.changeset/tasty-pugs-decide.md new file mode 100644 index 000000000..f13016f04 --- /dev/null +++ b/.changeset/tasty-pugs-decide.md @@ -0,0 +1,5 @@ +--- +'@qwik-ui/headless': patch +--- + +feat: implement a beta version of the Tooltip component 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/showcase/showcase.tsx b/apps/website/src/components/showcase/showcase.tsx index 62cf68f6f..aeba2405a 100644 --- a/apps/website/src/components/showcase/showcase.tsx +++ b/apps/website/src/components/showcase/showcase.tsx @@ -18,9 +18,13 @@ export const Showcase = component$(({ name, ...props }) => { const componentCodeSig = useSignal(); useTask$(async () => { - // 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](); + 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/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/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 000000000..51e8c3f3a Binary files /dev/null and b/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/closed-popover-popover-chrome-108-darwin.png differ 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 000000000..51e8c3f3a Binary files /dev/null and b/packages/kit-headless/src/components/popover/popover.test.ts-snapshots/opened-popover-popover-chrome-108-darwin.png differ 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 000000000..e006f2077 Binary files /dev/null and b/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/closed-tooltip-popover-chrome-108-darwin.png differ 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 000000000..8f3a2566a Binary files /dev/null and b/packages/kit-headless/src/components/tooltip/tooltip.test.ts-snapshots/opened-tooltip-popover-chrome-108-darwin.png differ diff --git a/packages/kit-headless/src/utils/test-utils.ts b/packages/kit-headless/src/utils/test-utils.ts new file mode 100644 index 000000000..f29eea881 --- /dev/null +++ b/packages/kit-headless/src/utils/test-utils.ts @@ -0,0 +1,8 @@ +import { expect } from '@playwright/test'; + +export function assertBoundingBoxExists( + box: { x: number; y: number; width: number; height: number } | null | undefined, +): asserts box is { x: number; y: number; width: number; height: number } { + expect(box).not.toBeNull(); + expect(box).not.toBeUndefined(); +} diff --git a/packages/themes/package.json b/packages/themes/package.json index d0d70578d..c8d2a4cd9 100644 --- a/packages/themes/package.json +++ b/packages/themes/package.json @@ -23,6 +23,5 @@ "peerDependencies": { "@builder.io/qwik": "1.7.2" }, - "devDependencies": { - } + "devDependencies": {} }