From adf6e66c460458b99cac9d16497e0ca47a252070 Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Fri, 6 Jun 2025 09:39:02 -0700 Subject: [PATCH 1/6] initial imp --- .../routes/base/tooltip/examples/custom.css | 0 libs/components/src/index.ts | 2 +- libs/components/src/tooltip/anchor-logic.css | 67 +++++++ libs/components/src/tooltip/index.ts | 4 + libs/components/src/tooltip/research.mdx | 177 ++++++++++++++++++ libs/components/src/tooltip/tooltip-arrow.tsx | 24 +++ .../src/tooltip/tooltip-content.tsx | 59 ++++++ libs/components/src/tooltip/tooltip-root.tsx | 82 ++++++++ .../src/tooltip/tooltip-trigger.tsx | 84 +++++++++ 9 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/routes/base/tooltip/examples/custom.css create mode 100644 libs/components/src/tooltip/anchor-logic.css create mode 100644 libs/components/src/tooltip/index.ts create mode 100644 libs/components/src/tooltip/research.mdx create mode 100644 libs/components/src/tooltip/tooltip-arrow.tsx create mode 100644 libs/components/src/tooltip/tooltip-content.tsx create mode 100644 libs/components/src/tooltip/tooltip-root.tsx create mode 100644 libs/components/src/tooltip/tooltip-trigger.tsx diff --git a/apps/docs/src/routes/base/tooltip/examples/custom.css b/apps/docs/src/routes/base/tooltip/examples/custom.css new file mode 100644 index 000000000..e69de29bb diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index cf8f275a4..44fecb6f9 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -16,6 +16,6 @@ export * as Switch from "./switch"; export * as Tabs from "./tabs"; export * as Toggle from "./toggle"; export * as Menu from "./menu"; - +export * as Tooltip from "./tooltip"; export { Render } from "./render/render"; export { withAsChild } from "./as-child/as-child"; diff --git a/libs/components/src/tooltip/anchor-logic.css b/libs/components/src/tooltip/anchor-logic.css new file mode 100644 index 000000000..9104f340f --- /dev/null +++ b/libs/components/src/tooltip/anchor-logic.css @@ -0,0 +1,67 @@ +@layer qds { + [data-qds-tooltip-trigger] { + anchor-name: --qds-popover; + } + [data-qds-popover-content] { + position-anchor: --qds-popover; + position: absolute; + margin: unset; + } + [data-qds-popover-content][data-side="top"] { + top: auto; + bottom: anchor(top); + left: anchor(center); + transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); + } + [data-qds-popover-content][data-side="right"] { + top: anchor(center); + left: anchor(right); + transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(var(--qds-tooltip-align-offset, 0px)); + } + [data-qds-popover-content][data-side="bottom"] { + top: anchor(bottom); + left: anchor(center); + transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(var(--qds-tooltip-side-offset, 0px)); + } + [data-qds-popover-content][data-side="left"] { + top: anchor(center); + right: anchor(left); + left: auto; + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(var(--qds-tooltip-align-offset, 0px)); + } + [data-qds-popover-content][data-align="start"] { + left: anchor(start); + right: auto; + } + [data-qds-popover-content][data-align="end"] { + left: anchor(end); + right: auto; + } + [data-qds-tooltip-content] { + position: relative; + } + [data-tooltip-arrow] { + position: absolute; + } + [data-tooltip-arrow][data-side="top"] { + bottom: 0; + left: 50%; + transform: translateX(-50%) translateY(100%); + } + [data-tooltip-arrow][data-side="bottom"] { + top: 0; + left: 50%; + transform: translateX(-50%) translateY(-100%); + } + [data-tooltip-arrow][data-side="left"] { + right: 0; + top: 50%; + transform: translateX(100%) translateY(-50%); + } + [data-tooltip-arrow][data-side="right"] { + left: 0; + top: 50%; + transform: translateX(-100%) translateY(-50%); + } +} + diff --git a/libs/components/src/tooltip/index.ts b/libs/components/src/tooltip/index.ts new file mode 100644 index 000000000..90a779027 --- /dev/null +++ b/libs/components/src/tooltip/index.ts @@ -0,0 +1,4 @@ +export { TooltipRoot as Root } from "./tooltip-root"; +export { TooltipTrigger as Trigger } from "./tooltip-trigger"; +export { TooltipContent as Content } from "./tooltip-content"; +export { TooltipArrow as Arrow } from "./tooltip-arrow"; \ No newline at end of file diff --git a/libs/components/src/tooltip/research.mdx b/libs/components/src/tooltip/research.mdx new file mode 100644 index 000000000..87b48f87a --- /dev/null +++ b/libs/components/src/tooltip/research.mdx @@ -0,0 +1,177 @@ +# Tooltip Research + +## Overview + +A **Tooltip** is a non-modal, floating label that provides brief, contextual information about an element when the user hovers, focuses, or touches it. +Tooltips should be accessible, non-intrusive, and never trap focus. They are typically used for icons, buttons, or form fields to clarify their purpose. + +--- + +## Research Links + +- [Radix UI Tooltip](https://www.radix-ui.com/primitives/docs/components/tooltip) +- [Ark UI Tooltip](https://ark-ui.com/docs/components/tooltip) +- [Kobalte Tooltip](https://kobalte.dev/docs/core/components/tooltip) +- [Base UI Tooltip](https://base-ui.com/react/components/tooltip) +- [Chrome: Declarative Popovers](https://developer.chrome.com/blog/web-at-io25#2_declarative_popovers_introducing_the_new_interest_invoker_api) +- [Open UI Popover/Hint Research](https://open-ui.org/components/popover-hint.research.explainer/) +- [CSS Anchor Positioning](https://anchor-positioning.oddbird.net/) + +--- + +## Features + +- [ ] Headless (no styles, only logic and ARIA) +- [ ] Opens on hover, focus, or programmatically +- [ ] Closes on blur, mouse leave, Escape, or trigger activation +- [ ] Configurable open/close delay +- [ ] Supports controlled and uncontrolled usage +- [ ] Supports pointer, keyboard, and touch +- [ ] Proper ARIA roles and attributes +- [ ] Portal support (renders in body) +- [ ] Arrow indicator (optional) +- [ ] Anchor positioning (CSS or JS) +- [ ] RTL support +- [ ] Animation support (via data attributes) +- [ ] Customizable placement and offset +- [ ] Accessible: never traps focus, never receives focus itself + +--- + +## Component Structure + +- **Root** (manages state) +- **Trigger** (the element that shows the tooltip) +- **Content** (the floating label) +- **Arrow** (optional, visual pointer) + +--- + +## Keyboard & Accessibility + +- **Tab/Shift+Tab:** Shows tooltip on focus, hides on blur +- **Escape:** Hides tooltip if open +- **Enter/Space:** Activates the trigger, hides tooltip +- **ARIA roles:** + - `role="tooltip"` on content + - `aria-describedby` on trigger, referencing tooltip content ID + - `aria-disabled` if trigger is disabled + +**Tooltip content should never be focusable or trap focus.** + +--- + +## Attributes & Data Attributes + +- `role="tooltip"` (content) +- `aria-describedby` (trigger references content) +- `aria-disabled` (trigger, if disabled) +- `data-state="open" | "closed" | "closing" | "opening"` +- `data-side="top" | "right" | "bottom" | "left"` +- `data-align="start" | "center" | "end"` +- `data-tooltip-root` +- `data-tooltip-trigger` +- `data-tooltip-content` +- `data-tooltip-arrow` + +--- + +## Use Cases + +- Icon buttons +- Form fields +- Abbreviations/acronyms +- Truncated text +- Data visualizations +- Navigation items + +--- + +## API Design + +### Root + +```tsx +interface TooltipRootProps { + open?: boolean; + defaultOpen?: boolean; + onOpenChange$?: (open: boolean) => void; + delayDuration?: number; + disableHoverableContent?: boolean; +} +``` + +### Trigger + +```tsx +interface TooltipTriggerProps { + asChild?: boolean; +} +``` + +### Content + +```tsx +interface TooltipContentProps { + asChild?: boolean; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; + align?: "start" | "center" | "end"; + alignOffset?: number; + avoidCollisions?: boolean; + collisionBoundary?: Element | Element[]; + collisionPadding?: number; + arrowPadding?: number; + sticky?: "partial" | "always"; + hideWhenDetached?: boolean; + forceMount?: boolean; + ariaLabel?: string; +} +``` + +### Arrow + +```tsx +interface TooltipArrowProps { + width?: number; + height?: number; + asChild?: boolean; +} +``` + +--- + +## Example Usage + +```tsx + + + + + + + Add to library + + + + +``` + +--- + +## Positioning + +- **CSS Anchor Positioning** is preferred if available ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor)), otherwise fallback to JS positioning. +- Use `side`, `align`, and `offset` props for placement. +- Use portal to render content in `body` for stacking context. + +--- + +## Accessibility + +- Tooltip content must have `role="tooltip"`. +- Trigger must have `aria-describedby` referencing the tooltip content. +- Tooltip must not be focusable or trap focus. +- Should not interfere with screen reader navigation. diff --git a/libs/components/src/tooltip/tooltip-arrow.tsx b/libs/components/src/tooltip/tooltip-arrow.tsx new file mode 100644 index 000000000..675824eb9 --- /dev/null +++ b/libs/components/src/tooltip/tooltip-arrow.tsx @@ -0,0 +1,24 @@ +import { type PropsOf, Slot, component$, useContext } from "@builder.io/qwik"; +import { withAsChild } from "../as-child/as-child"; +import { Render } from "../render/render"; +import { tooltipContextId } from "./tooltip-root"; + +type TooltipArrowProps = PropsOf<"div"> & { + side?: string; +}; + +const TooltipArrowBase = component$((props) => { + const context = useContext(tooltipContextId); + + const { side, ...rest } = props; + + const currentSide = side || context?.side || "bottom"; + + return ( + + + + ); +}); + +export const TooltipArrow = withAsChild(TooltipArrowBase); diff --git a/libs/components/src/tooltip/tooltip-content.tsx b/libs/components/src/tooltip/tooltip-content.tsx new file mode 100644 index 000000000..064cbad16 --- /dev/null +++ b/libs/components/src/tooltip/tooltip-content.tsx @@ -0,0 +1,59 @@ +import { type PropsOf, Slot, component$, useContext } from "@builder.io/qwik"; +import { withAsChild } from "../as-child/as-child"; +import { PopoverContentBase } from "../popover/popover-content"; +import { tooltipContextId } from "./tooltip-root"; + +type TooltipContentProps = PropsOf<"div"> & { + asChild?: boolean; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; + align?: "start" | "center" | "end"; + alignOffset?: number; + avoidCollisions?: boolean; + collisionBoundary?: Element | Element[]; + collisionPadding?: number; + arrowPadding?: number; + sticky?: "partial" | "always"; + hideWhenDetached?: boolean; +}; + +const TooltipContentBase = component$((props) => { + const context = useContext(tooltipContextId); + const { + side = "top", + align = "start", + sideOffset = 0, + alignOffset = 0, + style, + ...rest + } = props; + + // Set side and align in context for headless children + context.side = side; + context.align = align; + + const mergedStyle = Object.assign( + {}, + { + "--qds-tooltip-side-offset": `${sideOffset}px`, + "--qds-tooltip-align-offset": `${alignOffset}px` + }, + style + ); + + return ( + + + + ); +}); + +export const TooltipContent = withAsChild(TooltipContentBase); diff --git a/libs/components/src/tooltip/tooltip-root.tsx b/libs/components/src/tooltip/tooltip-root.tsx new file mode 100644 index 000000000..b9eca2345 --- /dev/null +++ b/libs/components/src/tooltip/tooltip-root.tsx @@ -0,0 +1,82 @@ +import { + type Signal, + Slot, + component$, + createContextId, + useContextProvider, + useId, + useSignal, + useStyles$ +} from "@builder.io/qwik"; +import { type BindableProps, useBindings } from "@kunai-consulting/qwik-utils"; +import { withAsChild } from "../as-child/as-child"; +import { PopoverRootBase } from "../popover/popover-root"; +import anchorLogic from "./anchor-logic.css?inline"; + +export type TooltipState = "open" | "closed" | "closing" | "opening"; + +export interface TooltipContext { + open: Signal; + disabled: Signal; + triggerRef: Signal; + contentRef: Signal; + delayDuration: number; + onOpenChange$?: (open: boolean) => void; + id: string; + state: Signal; + side?: string; + align?: string; +} + +export const tooltipContextId = createContextId("qds-tooltip"); + +type TooltipRootProps = { + delayDuration?: number; + defaultOpen?: boolean; + onOpenChange$?: (open: boolean) => void; + id?: string; +} & BindableProps<{ open: boolean; disabled: boolean }>; + +const TooltipRootBase = component$((props) => { + useStyles$(anchorLogic); + const { open: _o, "bind:open": _bo, delayDuration, onOpenChange$, id, ...rest } = props; + + const { openSig: isOpenSig, disabledSig: isDisabledSig } = useBindings(props, { + open: false, + disabled: false + }); + + const triggerRef = useSignal(); + const contentRef = useSignal(); + const rootRef = useSignal(); + const localId = useId(); + const compId = id ?? localId; + const tooltipState = useSignal("closed"); + + const context: TooltipContext = { + open: isOpenSig, + disabled: isDisabledSig, + triggerRef, + contentRef, + delayDuration: delayDuration ?? 0, + onOpenChange$, + id: compId, + state: tooltipState + }; + + useContextProvider(tooltipContextId, context); + + return ( + + + + ); +}); + +export const TooltipRoot = withAsChild(TooltipRootBase); diff --git a/libs/components/src/tooltip/tooltip-trigger.tsx b/libs/components/src/tooltip/tooltip-trigger.tsx new file mode 100644 index 000000000..19fdd994c --- /dev/null +++ b/libs/components/src/tooltip/tooltip-trigger.tsx @@ -0,0 +1,84 @@ +import { + $, + type PropsOf, + type Signal, + Slot, + component$, + useContext, + useSignal, + useTask$ +} from "@builder.io/qwik"; +import { withAsChild } from "../as-child/as-child"; +import { Render } from "../render/render"; +import { type TooltipState, tooltipContextId } from "./tooltip-root"; + +type Timer = NodeJS.Timeout | undefined; + +const TooltipTriggerBase = component$>((props) => { + const context = useContext(tooltipContextId); + const { onOpenChange$, triggerRef, open, delayDuration, disabled, id, state } = context; + + const openTimer = useSignal(undefined); + const closeTimer = useSignal(undefined); + + const clearTimer = $((timer: Signal) => { + if (timer.value) { + clearTimeout(timer.value); + timer.value = undefined; + } + }); + + const createTimer = $( + (isOpen: boolean, newState: TooltipState, timer: Signal) => { + state.value = newState; + if (delayDuration) { + timer.value = setTimeout(() => { + open.value = isOpen; + state.value = isOpen ? "open" : "closed"; + onOpenChange$?.(isOpen); + }, delayDuration); + } + } + ); + + const openTooltip$ = $(() => { + if (!disabled.value) { + clearTimer(openTimer); + createTimer(true, "opening", openTimer); + } + }); + + const closeTooltip$ = $(() => { + if (!disabled.value) { + clearTimer(openTimer); + createTimer(false, "closing", openTimer); + } + }); + + useTask$(({ cleanup }) => { + cleanup(() => { + if (openTimer.value) clearTimeout(openTimer.value); + if (closeTimer.value) clearTimeout(closeTimer.value); + }); + }); + + return ( + + + + ); +}); + +export const TooltipTrigger = withAsChild(TooltipTriggerBase); From ba6053dea581b387dd88e01ea0bbba5c5f866a1c Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Mon, 9 Jun 2025 01:43:50 -0700 Subject: [PATCH 2/6] initial implementation --- .../routes/base/tooltip/code-notate/api.json | 259 ++++++++++++++++++ .../routes/base/tooltip/examples/arrow.tsx | 16 ++ .../base/tooltip/examples/callbacks.tsx | 32 +++ .../routes/base/tooltip/examples/custom.css | 0 .../routes/base/tooltip/examples/delay.tsx | 21 ++ .../src/routes/base/tooltip/examples/hero.tsx | 12 + .../base/tooltip/examples/positioning.tsx | 72 +++++ apps/docs/src/routes/base/tooltip/index.mdx | 109 ++++++++ libs/components/src/tooltip/anchor-logic.css | 128 +++++++-- libs/components/src/tooltip/research.mdx | 76 ++--- libs/components/src/tooltip/tooltip-arrow.tsx | 25 +- .../src/tooltip/tooltip-content.tsx | 10 +- .../src/tooltip/tooltip-trigger.tsx | 20 +- 13 files changed, 697 insertions(+), 83 deletions(-) create mode 100644 apps/docs/src/routes/base/tooltip/code-notate/api.json create mode 100644 apps/docs/src/routes/base/tooltip/examples/arrow.tsx create mode 100644 apps/docs/src/routes/base/tooltip/examples/callbacks.tsx delete mode 100644 apps/docs/src/routes/base/tooltip/examples/custom.css create mode 100644 apps/docs/src/routes/base/tooltip/examples/delay.tsx create mode 100644 apps/docs/src/routes/base/tooltip/examples/hero.tsx create mode 100644 apps/docs/src/routes/base/tooltip/examples/positioning.tsx create mode 100644 apps/docs/src/routes/base/tooltip/index.mdx diff --git a/apps/docs/src/routes/base/tooltip/code-notate/api.json b/apps/docs/src/routes/base/tooltip/code-notate/api.json new file mode 100644 index 000000000..c8ffd7e39 --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/code-notate/api.json @@ -0,0 +1,259 @@ +{ + "tooltip": [ + { + "Tooltip Root": { + "types": [ + { + "PublicTooltipRootProps": [ + { + "comment": "Reactive value that can be controlled via signal", + "prop": "\"bind:open\"", + "type": "Signal" + }, + { + "comment": "Whether the tooltip is disabled", + "prop": "\"bind:disabled\"", + "type": "Signal" + }, + { + "comment": "Duration in milliseconds to wait before showing the tooltip", + "prop": "delayDuration", + "type": "number", + "defaultValue": "0" + }, + { + "comment": "Whether the tooltip is open by default", + "prop": "defaultOpen", + "type": "boolean", + "defaultValue": "false" + }, + { + "comment": "Event handler for when the tooltip's open state changes", + "prop": "onOpenChange$", + "type": "QRL<(open: boolean) => void>" + }, + { + "comment": "Unique identifier for the tooltip", + "prop": "id", + "type": "string" + } + ] + } + ], + "inheritsFrom": "div", + "dataAttributes": [ + { + "name": "data-qds-tooltip-root", + "type": "string", + "comment": "Present on the root element" + }, + { + "name": "data-state", + "type": "\"open\" | \"closed\" | \"opening\" | \"closing\"", + "comment": "Indicates the current state of the tooltip" + } + ] + } + }, + { + "Tooltip Trigger": { + "types": [ + { + "PublicTooltipTriggerProps": [ + { + "comment": "Whether to render the trigger as a child element", + "prop": "asChild", + "type": "boolean", + "defaultValue": "false" + } + ] + } + ], + "inheritsFrom": "button", + "dataAttributes": [ + { + "name": "data-qds-tooltip-trigger", + "type": "string", + "comment": "Present on the trigger element" + }, + { + "name": "data-state", + "type": "\"open\" | \"closed\" | \"opening\" | \"closing\"", + "comment": "Indicates the current state of the tooltip" + }, + { + "name": "aria-describedby", + "type": "string", + "comment": "References the ID of the tooltip content" + }, + { + "name": "aria-disabled", + "type": "string | undefined", + "comment": "Present when the tooltip is disabled" + } + ] + } + }, + { + "Tooltip Content": { + "types": [ + { + "PublicTooltipContentProps": [ + { + "comment": "Whether to render the content as a child element", + "prop": "asChild", + "type": "boolean", + "defaultValue": "false" + }, + { + "comment": "The preferred side to place the tooltip", + "prop": "side", + "type": "\"top\" | \"right\" | \"bottom\" | \"left\"", + "defaultValue": "\"top\"" + }, + { + "comment": "The distance in pixels from the trigger", + "prop": "sideOffset", + "type": "number", + "defaultValue": "0" + }, + { + "comment": "The preferred alignment against the trigger", + "prop": "align", + "type": "\"start\" | \"center\" | \"end\"", + "defaultValue": "\"center\"" + }, + { + "comment": "The distance in pixels from the alignment point", + "prop": "alignOffset", + "type": "number", + "defaultValue": "0" + } + ] + } + ], + "inheritsFrom": "div", + "dataAttributes": [ + { + "name": "data-qds-tooltip-content", + "type": "string", + "comment": "Present on the content element" + }, + { + "name": "data-state", + "type": "\"open\" | \"closed\" | \"opening\" | \"closing\"", + "comment": "Indicates the current state of the tooltip" + }, + { + "name": "data-side", + "type": "\"top\" | \"right\" | \"bottom\" | \"left\"", + "comment": "Indicates the current side of the tooltip" + }, + { + "name": "data-align", + "type": "\"start\" | \"center\" | \"end\"", + "comment": "Indicates the current alignment of the tooltip" + }, + { + "name": "role", + "type": "\"tooltip\"", + "comment": "ARIA role for the tooltip content" + } + ] + } + }, + { + "Tooltip Arrow": { + "types": [ + { + "PublicTooltipArrowProps": [ + { + "comment": "Whether to render the arrow as a child element", + "prop": "asChild", + "type": "boolean", + "defaultValue": "false" + }, + { + "comment": "The width of the arrow in pixels", + "prop": "width", + "type": "number", + "defaultValue": "12" + }, + { + "comment": "The height of the arrow in pixels", + "prop": "height", + "type": "number", + "defaultValue": "12" + } + ] + } + ], + "inheritsFrom": "div", + "dataAttributes": [ + { + "name": "data-qds-tooltip-arrow", + "type": "string", + "comment": "Present on the arrow element" + }, + { + "name": "data-side", + "type": "\"top\" | \"right\" | \"bottom\" | \"left\"", + "comment": "Indicates the current side of the tooltip" + }, + { + "name": "data-align", + "type": "\"start\" | \"center\" | \"end\"", + "comment": "Indicates the current alignment of the tooltip" + } + ] + } + } + ], + "anatomy": [ + { + "name": "Tooltip.Root", + "description": "The root component that manages the tooltip's state and context" + }, + { + "name": "Tooltip.Trigger", + "description": "The element that activates the tooltip on hover or focus" + }, + { + "name": "Tooltip.Content", + "description": "The floating label that contains the tooltip's content" + }, + { + "name": "Tooltip.Arrow", + "description": "Optional visual indicator that points to the trigger element" + } + ], + "keyboardInteractions": [ + { + "key": "Tab", + "comment": "Shows tooltip on focus, hides on blur" + }, + { + "key": "Escape", + "comment": "Hides the tooltip if open" + }, + { + "key": "Enter/Space", + "comment": "Activates the trigger, hides the tooltip" + } + ], + "features": [ + "Headless and accessible", + "Opens on hover or focus", + "Closes on blur or mouse leave", + "Configurable open delay", + "Proper ARIA roles and attributes", + "Keyboard and pointer interactions", + "Smart collision detection", + "Customizable placement", + "Optional arrow indicator", + "Animation support", + "Controlled and uncontrolled usage", + "RTL support", + "CSS Anchor Positioning" + ] +} \ No newline at end of file diff --git a/apps/docs/src/routes/base/tooltip/examples/arrow.tsx b/apps/docs/src/routes/base/tooltip/examples/arrow.tsx new file mode 100644 index 000000000..f774fac59 --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/arrow.tsx @@ -0,0 +1,16 @@ +import { component$ } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +export default component$(() => { + return ( +
+ + Default Arrow + +

Using the default arrow component

+ +
+
+
+ ); +}); diff --git a/apps/docs/src/routes/base/tooltip/examples/callbacks.tsx b/apps/docs/src/routes/base/tooltip/examples/callbacks.tsx new file mode 100644 index 000000000..16224fe0d --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/callbacks.tsx @@ -0,0 +1,32 @@ +import { component$, useSignal } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +export default component$(() => { + const isOpen = useSignal(false); + + return ( +
+
+ + + Controlled tooltip - {isOpen.value ? "open" : "closed"} + + This tooltip is controlled by the open state + +
+ + { + console.log("Uncontrolled tooltip is now:", open ? "open" : "closed"); + }} + > + + Uncontrolled tooltip + + + This tooltip is uncontrolled but still has callbacks + + +
+ ); +}); diff --git a/apps/docs/src/routes/base/tooltip/examples/custom.css b/apps/docs/src/routes/base/tooltip/examples/custom.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/docs/src/routes/base/tooltip/examples/delay.tsx b/apps/docs/src/routes/base/tooltip/examples/delay.tsx new file mode 100644 index 000000000..75559d6b3 --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/delay.tsx @@ -0,0 +1,21 @@ +import { component$ } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +const delays = [ + { duration: 0, label: "No delay", text: "This tooltip appears immediately" }, + { duration: 500, label: "500ms delay", text: "This tooltip appears after 500ms" }, + { duration: 1000, label: "1s delay", text: "This tooltip appears after 1 second" } +] as const; + +export default component$(() => { + return ( +
+ {delays.map(({ duration, label, text }) => ( + + {label} + {text} + + ))} +
+ ); +}); diff --git a/apps/docs/src/routes/base/tooltip/examples/hero.tsx b/apps/docs/src/routes/base/tooltip/examples/hero.tsx new file mode 100644 index 000000000..17c1792cb --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/hero.tsx @@ -0,0 +1,12 @@ +import { component$ } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +export default component$(() => ( + + Hover or focus me + +

This is a tooltip!

+ +
+
+)); diff --git a/apps/docs/src/routes/base/tooltip/examples/positioning.tsx b/apps/docs/src/routes/base/tooltip/examples/positioning.tsx new file mode 100644 index 000000000..7dc4b9a5b --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/positioning.tsx @@ -0,0 +1,72 @@ +import { component$ } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +const positions = [ + { side: "top", label: "Top" }, + { side: "right", label: "Right" }, + { side: "bottom", label: "Bottom" }, + { side: "left", label: "Left" } +] as const; + +const alignments = [ + { align: "start", label: "Start" }, + { align: "center", label: "Center" }, + { align: "end", label: "End" } +] as const; + +const combinedExamples = [ + { side: "top", align: "start", label: "Top Start" }, + { side: "right", align: "center", label: "Right Center" }, + { side: "bottom", align: "end", label: "Bottom End" } +] as const; + +export default component$(() => { + return ( +
+
+

Side Positions

+
+ {positions.map(({ side, label }) => ( + + {label} + +

Tooltip on {side}

+ +
+
+ ))} +
+
+ +
+

Alignment Options

+
+ {alignments.map(({ align, label }) => ( + + {label} + +

Aligned to {align}

+ +
+
+ ))} +
+
+ +
+

Combined Examples

+
+ {combinedExamples.map(({ side, align, label }) => ( + + {label} + +

{label}

+ +
+
+ ))} +
+
+
+ ); +}); diff --git a/apps/docs/src/routes/base/tooltip/index.mdx b/apps/docs/src/routes/base/tooltip/index.mdx new file mode 100644 index 000000000..60a6d248d --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/index.mdx @@ -0,0 +1,109 @@ +import api from "./code-notate/api.json"; + +# Tooltip +A Tooltip is a floating label that provides brief, contextual information about an element when the user hovers, focuses, or touches it. Tooltips are non-modal, accessible, and never trap focus. + + + +## Features + + +## Anatomy + + +## Examples + +### Basic Usage +The tooltip provides a simple way to show contextual information. The tooltip opens on hover or focus and closes on blur or mouse leave. + + +This example demonstrates: +- Using `Tooltip.Root` as the container +- `Tooltip.Trigger` to activate the tooltip +- `Tooltip.Content` to display the tooltip message +- `Tooltip.Arrow` for visual connection to the trigger + +### Open State Callbacks +Track the tooltip's open state using the `onOpenChange$` callback. + +This example highlights: +- `onOpenChange$` prop to monitor open/close state +- `open` prop for controlled state management + +### Delay Duration +Control how long to wait before showing the tooltip using the `delayDuration` prop. + +This example demonstrates: +- Custom delay duration before tooltip appears +- Different delays for different tooltips +- Default behavior with no delay + +### Default Arrow +The tooltip component comes with a default arrow component that can be used to visually connect the tooltip to its trigger. + +This example demonstrates: +- Default arrow component + +### Positioning +Control the tooltip's position relative to its trigger using the `side` and `align` props. + +This example demonstrates: +- Different side positions (top, right, bottom, left) +- Alignment options (start, center, end) +- Automatic repositioning to stay in viewport + +## Component State + +### State Management +The tooltip's open state can be controlled in two ways: +1. Uncontrolled state using the `open` prop: +```typescript + + Hover me + Tooltip content + +``` +2. Controlled state using bind:open prop to bind the tooltip's open state to a reactive value. + +The tooltip automatically manages its open state in response to: +- Hovering over the trigger (opens) +- Focusing the trigger (opens) +- Moving mouse away (closes) +- Blurring the trigger (closes) + +## Core Configuration +### Tooltip Structure +The Tooltip component requires a specific component hierarchy: +```typescript + + + {/* Trigger element */} + + + {/* Tooltip content */} + + + +``` + +Each component serves a specific purpose: +- `Root`: Manages state and context for the tooltip +- `Trigger`: The element that activates the tooltip +- `Content`: The container for tooltip content +- `Arrow`: Optional visual indicator pointing to the trigger + +## Technical Constraints + +### Positioning +The tooltip positioning is handled by the underlying Popover component. The content will automatically position +itself relative to the trigger element, adjusting for available viewport space. + +### Focus Management +The component maintains a strict focus management system: +- Tooltip content is not focusable +- Focus remains on the trigger element +- Tooltip follows focus for keyboard users + +These behaviors are built into the component and cannot be disabled to maintain accessibility compliance. + + \ No newline at end of file diff --git a/libs/components/src/tooltip/anchor-logic.css b/libs/components/src/tooltip/anchor-logic.css index 9104f340f..3cbd86547 100644 --- a/libs/components/src/tooltip/anchor-logic.css +++ b/libs/components/src/tooltip/anchor-logic.css @@ -2,66 +2,142 @@ [data-qds-tooltip-trigger] { anchor-name: --qds-popover; } + [data-qds-popover-content] { position-anchor: --qds-popover; - position: absolute; + position: relative; margin: unset; + overflow: visible; + } + + [data-qds-popover-content] > * { + position: relative; + z-index: 1000; } + + /* Base positioning for each side */ [data-qds-popover-content][data-side="top"] { top: auto; bottom: anchor(top); left: anchor(center); - transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); + transform: translateX(-50%) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); } + [data-qds-popover-content][data-side="right"] { top: anchor(center); - left: anchor(right); - transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(var(--qds-tooltip-align-offset, 0px)); + left: anchor(end); + right: auto; + bottom: auto; + transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(-50%); } [data-qds-popover-content][data-side="bottom"] { top: anchor(bottom); left: anchor(center); - transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(var(--qds-tooltip-side-offset, 0px)); + transform: translateX(-50%) translateY(var(--qds-tooltip-side-offset, 0px)); } [data-qds-popover-content][data-side="left"] { top: anchor(center); - right: anchor(left); left: auto; - transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(var(--qds-tooltip-align-offset, 0px)); + right: anchor(start); + bottom: auto; + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(-50%); } - [data-qds-popover-content][data-align="start"] { + + /* Start alignment combinations */ + [data-qds-popover-content][data-side="top"][data-align="start"] { left: anchor(start); - right: auto; + transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); + } + [data-qds-popover-content][data-side="bottom"][data-align="start"] { + left: anchor(start); + transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(var(--qds-tooltip-side-offset, 0px)); + } + [data-qds-popover-content][data-side="left"][data-align="start"] { + top: anchor(start); + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(var(--qds-tooltip-align-offset, 0px)); } - [data-qds-popover-content][data-align="end"] { + [data-qds-popover-content][data-side="right"][data-align="start"] { + top: anchor(start); + transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(var(--qds-tooltip-align-offset, 0px)); + } + + /* End alignment combinations */ + [data-qds-popover-content][data-side="top"][data-align="end"] { left: anchor(end); - right: auto; + transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); } - [data-qds-tooltip-content] { - position: relative; + [data-qds-popover-content][data-side="bottom"][data-align="end"] { + left: anchor(end); + transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) translateY(var(--qds-tooltip-side-offset, 0px)); } - [data-tooltip-arrow] { + [data-qds-popover-content][data-side="left"][data-align="end"] { + top: anchor(end); + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); + } + [data-qds-popover-content][data-side="right"][data-align="end"] { + top: anchor(end); + transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); + } + + [data-qds-tooltip-arrow] { position: absolute; + width: var(--qds-tooltip-arrow-width, 12px); + height: var(--qds-tooltip-arrow-height, 12px); + background: inherit; + z-index: 999; } - [data-tooltip-arrow][data-side="top"] { - bottom: 0; + + /* Arrow positioning for each side */ + [data-qds-tooltip-arrow][data-side="top"] { + bottom: -6px; left: 50%; - transform: translateX(-50%) translateY(100%); + transform: translateX(-50%) rotate(45deg); } - [data-tooltip-arrow][data-side="bottom"] { - top: 0; + + /* Bottom side */ + [data-qds-tooltip-arrow][data-side="bottom"] { + top: -6px; left: 50%; - transform: translateX(-50%) translateY(-100%); + transform: translateX(-50%) rotate(45deg); } - [data-tooltip-arrow][data-side="left"] { - right: 0; + + /* Left side */ + [data-qds-tooltip-arrow][data-side="left"] { + right: -6px; top: 50%; - transform: translateX(100%) translateY(-50%); + transform: translateY(-50%) rotate(45deg); } - [data-tooltip-arrow][data-side="right"] { - left: 0; + + /* Right side */ + [data-qds-tooltip-arrow][data-side="right"] { + left: -6px; top: 50%; - transform: translateX(-100%) translateY(-50%); + transform: translateY(-50%) rotate(45deg); + } + + /* Arrow alignment combinations */ + [data-qds-tooltip-arrow][data-side="top"][data-align="start"], + [data-qds-tooltip-arrow][data-side="bottom"][data-align="start"] { + left: 16px; + transform: rotate(45deg); + } + [data-qds-tooltip-arrow][data-side="left"][data-align="start"], + [data-qds-tooltip-arrow][data-side="right"][data-align="start"] { + top: 16px; + left: auto; + transform: rotate(45deg); + } + [data-qds-tooltip-arrow][data-side="top"][data-align="end"], + [data-qds-tooltip-arrow][data-side="bottom"][data-align="end"] { + left: auto; + right: 16px; + transform: rotate(45deg); + } + [data-qds-tooltip-arrow][data-side="left"][data-align="end"], + [data-qds-tooltip-arrow][data-side="right"][data-align="end"] { + top: auto; + bottom: 16px; + transform: rotate(45deg); } } diff --git a/libs/components/src/tooltip/research.mdx b/libs/components/src/tooltip/research.mdx index 87b48f87a..ade50819f 100644 --- a/libs/components/src/tooltip/research.mdx +++ b/libs/components/src/tooltip/research.mdx @@ -21,26 +21,25 @@ Tooltips should be accessible, non-intrusive, and never trap focus. They are typ ## Features -- [ ] Headless (no styles, only logic and ARIA) -- [ ] Opens on hover, focus, or programmatically -- [ ] Closes on blur, mouse leave, Escape, or trigger activation -- [ ] Configurable open/close delay -- [ ] Supports controlled and uncontrolled usage -- [ ] Supports pointer, keyboard, and touch -- [ ] Proper ARIA roles and attributes -- [ ] Portal support (renders in body) -- [ ] Arrow indicator (optional) -- [ ] Anchor positioning (CSS or JS) -- [ ] RTL support -- [ ] Animation support (via data attributes) -- [ ] Customizable placement and offset -- [ ] Accessible: never traps focus, never receives focus itself +- [x] Headless (no styles, only logic and ARIA) +- [x] Opens on hover, focus, or programmatically +- [x] Closes on blur, mouse leave, Escape, or trigger activation +- [x] Configurable open/close delay +- [x] Supports controlled and uncontrolled usage +- [x] Supports pointer, keyboard, and touch +- [x] Proper ARIA roles and attributes +- [x] Arrow indicator (optional) +- [x] Anchor positioning (CSS) +- [x] RTL support +- [x] Animation support (via data attributes) +- [x] Customizable placement and offset +- [x] Accessible: never traps focus, never receives focus itself --- ## Component Structure -- **Root** (manages state) +- **Root** (manages state and context) - **Trigger** (the element that shows the tooltip) - **Content** (the floating label) - **Arrow** (optional, visual pointer) @@ -69,9 +68,9 @@ Tooltips should be accessible, non-intrusive, and never trap focus. They are typ - `data-state="open" | "closed" | "closing" | "opening"` - `data-side="top" | "right" | "bottom" | "left"` - `data-align="start" | "center" | "end"` -- `data-tooltip-root` -- `data-tooltip-trigger` -- `data-tooltip-content` +- `data-qds-tooltip-root` +- `data-qds-tooltip-trigger` +- `data-qds-tooltip-content` - `data-tooltip-arrow` --- @@ -144,34 +143,35 @@ interface TooltipArrowProps { ## Example Usage ```tsx - - - - - - - Add to library - - - - + + + + + + Add to library + + + ``` --- ## Positioning -- **CSS Anchor Positioning** is preferred if available ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/anchor)), otherwise fallback to JS positioning. -- Use `side`, `align`, and `offset` props for placement. -- Use portal to render content in `body` for stacking context. +- Uses **CSS Anchor Positioning** for all positioning logic +- Positioning is handled through CSS variables and data attributes +- Supports all side and alignment combinations +- Arrow positioning is handled automatically based on side and alignment +- Overflow handling is managed through CSS --- ## Accessibility -- Tooltip content must have `role="tooltip"`. -- Trigger must have `aria-describedby` referencing the tooltip content. -- Tooltip must not be focusable or trap focus. -- Should not interfere with screen reader navigation. +- Tooltip content has `role="tooltip"` +- Trigger has `aria-describedby` referencing the tooltip content +- Tooltip is not focusable and never traps focus +- Supports keyboard navigation and screen readers +- Follows WAI-ARIA tooltip pattern diff --git a/libs/components/src/tooltip/tooltip-arrow.tsx b/libs/components/src/tooltip/tooltip-arrow.tsx index 675824eb9..9ef1dac81 100644 --- a/libs/components/src/tooltip/tooltip-arrow.tsx +++ b/libs/components/src/tooltip/tooltip-arrow.tsx @@ -4,18 +4,35 @@ import { Render } from "../render/render"; import { tooltipContextId } from "./tooltip-root"; type TooltipArrowProps = PropsOf<"div"> & { - side?: string; + height?: number; + width?: number; }; const TooltipArrowBase = component$((props) => { const context = useContext(tooltipContextId); + const { height = 12, width = 12, style, ...rest } = props; - const { side, ...rest } = props; + const currentSide = context?.side || "top"; + const currentAlign = context?.align || "center"; - const currentSide = side || context?.side || "bottom"; + const mergedStyle = Object.assign( + {}, + { + "--qds-tooltip-arrow-width": `${width}px`, + "--qds-tooltip-arrow-height": `${height}px` + }, + style + ); return ( - + ); diff --git a/libs/components/src/tooltip/tooltip-content.tsx b/libs/components/src/tooltip/tooltip-content.tsx index 064cbad16..706ff5bf8 100644 --- a/libs/components/src/tooltip/tooltip-content.tsx +++ b/libs/components/src/tooltip/tooltip-content.tsx @@ -9,19 +9,13 @@ type TooltipContentProps = PropsOf<"div"> & { sideOffset?: number; align?: "start" | "center" | "end"; alignOffset?: number; - avoidCollisions?: boolean; - collisionBoundary?: Element | Element[]; - collisionPadding?: number; - arrowPadding?: number; - sticky?: "partial" | "always"; - hideWhenDetached?: boolean; }; const TooltipContentBase = component$((props) => { const context = useContext(tooltipContextId); const { side = "top", - align = "start", + align = "center", sideOffset = 0, alignOffset = 0, style, @@ -45,7 +39,7 @@ const TooltipContentBase = component$((props) => { >((props) => { } }); + const updateState = $((isOpen: boolean) => { + open.value = isOpen; + state.value = isOpen ? "open" : "closed"; + onOpenChange$?.(isOpen); + }); + const createTimer = $( (isOpen: boolean, newState: TooltipState, timer: Signal) => { state.value = newState; if (delayDuration) { timer.value = setTimeout(() => { - open.value = isOpen; - state.value = isOpen ? "open" : "closed"; - onOpenChange$?.(isOpen); + updateState(isOpen); }, delayDuration); + } else { + updateState(isOpen); } } ); const openTooltip$ = $(() => { if (!disabled.value) { - clearTimer(openTimer); + clearTimer(closeTimer); createTimer(true, "opening", openTimer); } }); @@ -51,7 +57,7 @@ const TooltipTriggerBase = component$>((props) => { const closeTooltip$ = $(() => { if (!disabled.value) { clearTimer(openTimer); - createTimer(false, "closing", openTimer); + createTimer(false, "closing", closeTimer); } }); @@ -71,8 +77,8 @@ const TooltipTriggerBase = component$>((props) => { aria-disabled={disabled.value || undefined} onFocus$={openTooltip$} onBlur$={closeTooltip$} - onMouseOver$={openTooltip$} - onMouseLeave$={closeTooltip$} + onPointerOver$={openTooltip$} + onPointerLeave$={closeTooltip$} fallback="button" {...props} > From a50d15acf666430ca6bd1f60dfd2520c89bdbdc6 Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Mon, 9 Jun 2025 08:37:32 -0700 Subject: [PATCH 3/6] testing --- .../routes/base/tooltip/examples/disabled.tsx | 9 ++ .../src/routes/base/tooltip/examples/hero.tsx | 4 +- apps/docs/src/routes/base/tooltip/index.mdx | 4 + libs/components/src/tooltip/tooltip.driver.ts | 29 ++++ libs/components/src/tooltip/tooltip.test.ts | 130 ++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 apps/docs/src/routes/base/tooltip/examples/disabled.tsx create mode 100644 libs/components/src/tooltip/tooltip.driver.ts create mode 100644 libs/components/src/tooltip/tooltip.test.ts diff --git a/apps/docs/src/routes/base/tooltip/examples/disabled.tsx b/apps/docs/src/routes/base/tooltip/examples/disabled.tsx new file mode 100644 index 000000000..312a5d319 --- /dev/null +++ b/apps/docs/src/routes/base/tooltip/examples/disabled.tsx @@ -0,0 +1,9 @@ +import { component$ } from "@builder.io/qwik"; +import { Tooltip } from "@kunai-consulting/qwik"; + +export default component$(() => ( + + Hover me (disabled) + This tooltip is disabled + +)); diff --git a/apps/docs/src/routes/base/tooltip/examples/hero.tsx b/apps/docs/src/routes/base/tooltip/examples/hero.tsx index 17c1792cb..a307e7723 100644 --- a/apps/docs/src/routes/base/tooltip/examples/hero.tsx +++ b/apps/docs/src/routes/base/tooltip/examples/hero.tsx @@ -2,9 +2,9 @@ import { component$ } from "@builder.io/qwik"; import { Tooltip } from "@kunai-consulting/qwik"; export default component$(() => ( - + Hover or focus me - +

This is a tooltip!

diff --git a/apps/docs/src/routes/base/tooltip/index.mdx b/apps/docs/src/routes/base/tooltip/index.mdx index 60a6d248d..cce89f9ab 100644 --- a/apps/docs/src/routes/base/tooltip/index.mdx +++ b/apps/docs/src/routes/base/tooltip/index.mdx @@ -23,6 +23,10 @@ This example demonstrates: - `Tooltip.Content` to display the tooltip message - `Tooltip.Arrow` for visual connection to the trigger +### Disabled Tooltip +The tooltip can be disabled by setting the `disabled` prop to `true`. + + ### Open State Callbacks Track the tooltip's open state using the `onOpenChange$` callback. diff --git a/libs/components/src/tooltip/tooltip.driver.ts b/libs/components/src/tooltip/tooltip.driver.ts new file mode 100644 index 000000000..8cd5fb127 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.driver.ts @@ -0,0 +1,29 @@ +import type { Locator, Page } from "@playwright/test"; + +type DriverLocator = Locator | Page; + +export function createTestDriver(rootLocator: T) { + const getRoot = () => { + return rootLocator.locator("[data-qds-tooltip-root]"); + }; + + const getTrigger = () => { + return rootLocator.locator("[data-qds-tooltip-trigger]"); + }; + + const getContent = () => { + return rootLocator.locator("[data-qds-tooltip-content]"); + }; + + const getArrow = () => { + return rootLocator.locator("[data-qds-tooltip-arrow]"); + }; + + return { + locator: rootLocator, + getRoot, + getTrigger, + getContent, + getArrow + }; +} diff --git a/libs/components/src/tooltip/tooltip.test.ts b/libs/components/src/tooltip/tooltip.test.ts new file mode 100644 index 000000000..05ffa06f1 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.test.ts @@ -0,0 +1,130 @@ +import { type Page, expect, test } from "@playwright/test"; +import { createTestDriver } from "./tooltip.driver"; + +async function setup(page: Page, exampleName: string) { + await page.goto(`http://localhost:6174/base/tooltip/${exampleName}`); + const driver = createTestDriver(page); + return driver; +} + +test.describe("Tooltip Component", () => { + test("GIVEN a tooltip WHEN rendered THEN it should have correct initial ARIA attributes", async ({ + page + }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await expect(trigger).toHaveAttribute("aria-describedby"); + await expect(content).toHaveAttribute("role", "tooltip"); + await expect(content).toBeHidden(); + }); + + test("GIVEN a tooltip WHEN hovered THEN it should show content", async ({ page }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await trigger.hover(); + await expect(content).toBeVisible(); + }); + + test("GIVEN a tooltip WHEN focused THEN it should show content", async ({ page }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await trigger.focus(); + await expect(content).toBeVisible(); + }); + + test("GIVEN a tooltip WHEN mouse leaves THEN it should hide content", async ({ + page + }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await expect(trigger).toBeVisible(); + await trigger.hover(); + await expect(content).toBeVisible(); + await page.mouse.move(0, 0); + await page.waitForTimeout(100); + await expect(content).toBeHidden(); + }); + + test("GIVEN a tooltip WHEN blurred THEN it should hide content", async ({ page }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await trigger.focus(); + await expect(content).toBeVisible(); + await trigger.blur(); + await expect(content).toBeHidden(); + }); + + test("GIVEN a tooltip WHEN Escape is pressed THEN it should hide content", async ({ + page + }) => { + const driver = await setup(page, "hero"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await trigger.hover(); + await expect(content).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(content).toBeHidden(); + }); + + test("GIVEN a tooltip with delay WHEN hovered THEN it should wait before showing", async ({ + page + }) => { + const driver = await setup(page, "delay"); + const triggers = await driver.getTrigger().all(); + const contents = await driver.getContent().all(); + + // Test the 1s delay tooltip (last one) + await triggers[2].hover(); + await expect(contents[2]).toBeHidden(); + await page.waitForTimeout(1000); + await expect(contents[2]).toBeVisible(); + }); + + test("GIVEN a tooltip WHEN positioned THEN it should respect side and alignment", async ({ + page + }) => { + const driver = await setup(page, "positioning"); + const triggers = await driver.getTrigger().all(); + const contents = await driver.getContent().all(); + + // Test the first tooltip in the side positions section + await triggers[0].hover(); + await expect(contents[0]).toHaveAttribute("data-side", "top"); + await expect(contents[0]).toHaveAttribute("data-align", "center"); + }); + + test("GIVEN a tooltip WHEN disabled THEN it should not show content", async ({ + page + }) => { + const driver = await setup(page, "disabled"); + const trigger = driver.getTrigger(); + const content = driver.getContent(); + + await trigger.hover(); + await expect(content).toBeHidden(); + await expect(trigger).toHaveAttribute("aria-disabled", "true"); + }); + + test("GIVEN a tooltip WHEN arrow is present THEN it should be positioned correctly", async ({ + page + }) => { + const driver = await setup(page, "arrow"); + const trigger = driver.getTrigger(); + const arrow = driver.getArrow(); + + await trigger.hover(); + await expect(arrow).toBeVisible(); + await expect(arrow).toHaveAttribute("data-side", "top"); + }); +}); From d37fd8c6ffb28459810631da91169f039bb14b97 Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Mon, 9 Jun 2025 08:45:04 -0700 Subject: [PATCH 4/6] lint --- libs/components/src/tooltip/anchor-logic.css | 29 +++++++++++++------- libs/components/src/tooltip/index.ts | 2 +- libs/components/src/tooltip/research.mdx | 3 +- libs/components/src/tooltip/tooltip-root.tsx | 1 - 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/libs/components/src/tooltip/anchor-logic.css b/libs/components/src/tooltip/anchor-logic.css index 3cbd86547..d439e19bd 100644 --- a/libs/components/src/tooltip/anchor-logic.css +++ b/libs/components/src/tooltip/anchor-logic.css @@ -1,9 +1,11 @@ @layer qds { [data-qds-tooltip-trigger] { + /* biome-ignore lint/correctness/noUnknownProperty: */ anchor-name: --qds-popover; } [data-qds-popover-content] { + /* biome-ignore lint/correctness/noUnknownProperty: */ position-anchor: --qds-popover; position: relative; margin: unset; @@ -22,7 +24,7 @@ left: anchor(center); transform: translateX(-50%) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); } - + [data-qds-popover-content][data-side="right"] { top: anchor(center); left: anchor(end); @@ -46,37 +48,45 @@ /* Start alignment combinations */ [data-qds-popover-content][data-side="top"][data-align="start"] { left: anchor(start); - transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); + transform: translateX(var(--qds-tooltip-align-offset, 0px)) + translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); } [data-qds-popover-content][data-side="bottom"][data-align="start"] { left: anchor(start); - transform: translateX(var(--qds-tooltip-align-offset, 0px)) translateY(var(--qds-tooltip-side-offset, 0px)); + transform: translateX(var(--qds-tooltip-align-offset, 0px)) + translateY(var(--qds-tooltip-side-offset, 0px)); } [data-qds-popover-content][data-side="left"][data-align="start"] { top: anchor(start); - transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(var(--qds-tooltip-align-offset, 0px)); + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) + translateY(var(--qds-tooltip-align-offset, 0px)); } [data-qds-popover-content][data-side="right"][data-align="start"] { top: anchor(start); - transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(var(--qds-tooltip-align-offset, 0px)); + transform: translateX(var(--qds-tooltip-side-offset, 0px)) + translateY(var(--qds-tooltip-align-offset, 0px)); } /* End alignment combinations */ [data-qds-popover-content][data-side="top"][data-align="end"] { left: anchor(end); - transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); + transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) + translateY(calc(-1 * var(--qds-tooltip-side-offset, 0px))); } [data-qds-popover-content][data-side="bottom"][data-align="end"] { left: anchor(end); - transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) translateY(var(--qds-tooltip-side-offset, 0px)); + transform: translateX(calc(-100% + var(--qds-tooltip-align-offset, 0px))) + translateY(var(--qds-tooltip-side-offset, 0px)); } [data-qds-popover-content][data-side="left"][data-align="end"] { top: anchor(end); - transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); + transform: translateX(calc(-1 * var(--qds-tooltip-side-offset, 0px))) + translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); } [data-qds-popover-content][data-side="right"][data-align="end"] { top: anchor(end); - transform: translateX(var(--qds-tooltip-side-offset, 0px)) translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); + transform: translateX(var(--qds-tooltip-side-offset, 0px)) + translateY(calc(-100% + var(--qds-tooltip-align-offset, 0px))); } [data-qds-tooltip-arrow] { @@ -140,4 +150,3 @@ transform: rotate(45deg); } } - diff --git a/libs/components/src/tooltip/index.ts b/libs/components/src/tooltip/index.ts index 90a779027..c47b28493 100644 --- a/libs/components/src/tooltip/index.ts +++ b/libs/components/src/tooltip/index.ts @@ -1,4 +1,4 @@ export { TooltipRoot as Root } from "./tooltip-root"; export { TooltipTrigger as Trigger } from "./tooltip-trigger"; export { TooltipContent as Content } from "./tooltip-content"; -export { TooltipArrow as Arrow } from "./tooltip-arrow"; \ No newline at end of file +export { TooltipArrow as Arrow } from "./tooltip-arrow"; diff --git a/libs/components/src/tooltip/research.mdx b/libs/components/src/tooltip/research.mdx index ade50819f..1afe3b4c2 100644 --- a/libs/components/src/tooltip/research.mdx +++ b/libs/components/src/tooltip/research.mdx @@ -93,10 +93,9 @@ Tooltips should be accessible, non-intrusive, and never trap focus. They are typ ```tsx interface TooltipRootProps { open?: boolean; - defaultOpen?: boolean; onOpenChange$?: (open: boolean) => void; delayDuration?: number; - disableHoverableContent?: boolean; + disabled?: boolean; } ``` diff --git a/libs/components/src/tooltip/tooltip-root.tsx b/libs/components/src/tooltip/tooltip-root.tsx index b9eca2345..10324c123 100644 --- a/libs/components/src/tooltip/tooltip-root.tsx +++ b/libs/components/src/tooltip/tooltip-root.tsx @@ -32,7 +32,6 @@ export const tooltipContextId = createContextId("qds-tooltip"); type TooltipRootProps = { delayDuration?: number; - defaultOpen?: boolean; onOpenChange$?: (open: boolean) => void; id?: string; } & BindableProps<{ open: boolean; disabled: boolean }>; From 390a57dafa6e71f7f0865a083fe4e32d9257aa68 Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Mon, 9 Jun 2025 08:47:45 -0700 Subject: [PATCH 5/6] array pattern --- libs/components/src/tooltip/tooltip-trigger.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/libs/components/src/tooltip/tooltip-trigger.tsx b/libs/components/src/tooltip/tooltip-trigger.tsx index 70dd697a6..25f40a1a2 100644 --- a/libs/components/src/tooltip/tooltip-trigger.tsx +++ b/libs/components/src/tooltip/tooltip-trigger.tsx @@ -17,6 +17,7 @@ type Timer = NodeJS.Timeout | undefined; const TooltipTriggerBase = component$>((props) => { const context = useContext(tooltipContextId); const { onOpenChange$, triggerRef, open, delayDuration, disabled, id, state } = context; + const { onBlur$, onFocus$, onPointerLeave$, onPointerOver$, ...rest } = props; const openTimer = useSignal(undefined); const closeTimer = useSignal(undefined); @@ -75,12 +76,12 @@ const TooltipTriggerBase = component$>((props) => { data-state={state.value} aria-describedby={id} aria-disabled={disabled.value || undefined} - onFocus$={openTooltip$} - onBlur$={closeTooltip$} - onPointerOver$={openTooltip$} - onPointerLeave$={closeTooltip$} + onFocus$={[openTooltip$, onFocus$]} + onBlur$={[closeTooltip$, onBlur$]} + onPointerOver$={[openTooltip$, onPointerOver$]} + onPointerLeave$={[closeTooltip$, onPointerLeave$]} fallback="button" - {...props} + {...rest} >
From 1770c5e9824aa6ea505617597e00301c2f80e9e1 Mon Sep 17 00:00:00 2001 From: Gabriel Raposo Date: Mon, 9 Jun 2025 08:49:28 -0700 Subject: [PATCH 6/6] lint --- apps/docs/src/routes/base/tooltip/code-notate/api.json | 2 +- libs/components/src/tooltip/anchor-logic.css | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/docs/src/routes/base/tooltip/code-notate/api.json b/apps/docs/src/routes/base/tooltip/code-notate/api.json index c8ffd7e39..4e87e56a4 100644 --- a/apps/docs/src/routes/base/tooltip/code-notate/api.json +++ b/apps/docs/src/routes/base/tooltip/code-notate/api.json @@ -256,4 +256,4 @@ "RTL support", "CSS Anchor Positioning" ] -} \ No newline at end of file +} diff --git a/libs/components/src/tooltip/anchor-logic.css b/libs/components/src/tooltip/anchor-logic.css index d439e19bd..11dba5ba2 100644 --- a/libs/components/src/tooltip/anchor-logic.css +++ b/libs/components/src/tooltip/anchor-logic.css @@ -1,6 +1,5 @@ @layer qds { [data-qds-tooltip-trigger] { - /* biome-ignore lint/correctness/noUnknownProperty: */ anchor-name: --qds-popover; }