From 9e93dacad87ed87942129c7e7678881824aee06e Mon Sep 17 00:00:00 2001 From: peoray Date: Wed, 10 Jun 2026 13:15:19 +0100 Subject: [PATCH 1/8] feat: add tooltip components --- docs/.vitepress/config.ts | 1 + docs/components/demo/Tooltip/css/index.vue | 61 ++++ docs/components/demo/Tooltip/css/styles.css | 121 +++++++ .../demo/Tooltip/tailwind/index.vue | 63 ++++ .../demo/TooltipControlled/css/index.vue | 70 ++++ .../demo/TooltipControlled/css/styles.css | 50 +++ .../demo/TooltipControlled/tailwind/index.vue | 73 ++++ .../demo/TooltipDetached/css/index.vue | 39 ++ .../demo/TooltipDetached/css/styles.css | 40 +++ .../demo/TooltipDetached/tailwind/index.vue | 40 +++ .../demo/TooltipMultiple/css/index.vue | 73 ++++ .../demo/TooltipMultiple/css/styles.css | 58 +++ .../demo/TooltipMultiple/tailwind/index.vue | 62 ++++ docs/content/docs/components/tooltip.md | 245 +++++++++++++ packages/core/src/index.ts | 1 + .../core/src/tooltip/arrow/TooltipArrow.vue | 79 +++++ .../arrow/TooltipArrowDataAttributes.ts | 8 + packages/core/src/tooltip/index.ts | 32 ++ .../core/src/tooltip/popup/TooltipPopup.vue | 181 ++++++++++ .../popup/TooltipPopupDataAttributes.ts | 9 + .../core/src/tooltip/portal/TooltipPortal.vue | 28 ++ .../tooltip/portal/TooltipPortalContext.ts | 9 + .../tooltip/positioner/TooltipPositioner.vue | 231 ++++++++++++ .../positioner/TooltipPositionerContext.ts | 27 ++ .../positioner/TooltipPositionerCssVars.ts | 9 + .../TooltipPositionerDataAttributes.ts | 7 + .../src/tooltip/provider/TooltipProvider.vue | 52 +++ .../provider/TooltipProviderContext.ts | 19 + .../core/src/tooltip/root/TooltipRoot.test.ts | 272 ++++++++++++++ .../core/src/tooltip/root/TooltipRoot.vue | 333 ++++++++++++++++++ .../src/tooltip/root/TooltipRootContext.ts | 89 +++++ .../core/src/tooltip/store/TooltipHandle.ts | 142 ++++++++ packages/core/src/tooltip/tooltip.types.ts | 217 ++++++++++++ .../src/tooltip/trigger/TooltipTrigger.vue | 268 ++++++++++++++ .../trigger/TooltipTriggerDataAttributes.ts | 10 + packages/core/src/tooltip/utils/constants.ts | 1 + .../src/tooltip/utils/popupStateMapping.ts | 99 ++++++ .../src/tooltip/viewport/TooltipViewport.vue | 97 +++++ .../viewport/TooltipViewportCssVars.ts | 4 + .../viewport/TooltipViewportDataAttributes.ts | 5 + 40 files changed, 3225 insertions(+) create mode 100644 docs/components/demo/Tooltip/css/index.vue create mode 100644 docs/components/demo/Tooltip/css/styles.css create mode 100644 docs/components/demo/Tooltip/tailwind/index.vue create mode 100644 docs/components/demo/TooltipControlled/css/index.vue create mode 100644 docs/components/demo/TooltipControlled/css/styles.css create mode 100644 docs/components/demo/TooltipControlled/tailwind/index.vue create mode 100644 docs/components/demo/TooltipDetached/css/index.vue create mode 100644 docs/components/demo/TooltipDetached/css/styles.css create mode 100644 docs/components/demo/TooltipDetached/tailwind/index.vue create mode 100644 docs/components/demo/TooltipMultiple/css/index.vue create mode 100644 docs/components/demo/TooltipMultiple/css/styles.css create mode 100644 docs/components/demo/TooltipMultiple/tailwind/index.vue create mode 100644 docs/content/docs/components/tooltip.md create mode 100644 packages/core/src/tooltip/arrow/TooltipArrow.vue create mode 100644 packages/core/src/tooltip/arrow/TooltipArrowDataAttributes.ts create mode 100644 packages/core/src/tooltip/index.ts create mode 100644 packages/core/src/tooltip/popup/TooltipPopup.vue create mode 100644 packages/core/src/tooltip/popup/TooltipPopupDataAttributes.ts create mode 100644 packages/core/src/tooltip/portal/TooltipPortal.vue create mode 100644 packages/core/src/tooltip/portal/TooltipPortalContext.ts create mode 100644 packages/core/src/tooltip/positioner/TooltipPositioner.vue create mode 100644 packages/core/src/tooltip/positioner/TooltipPositionerContext.ts create mode 100644 packages/core/src/tooltip/positioner/TooltipPositionerCssVars.ts create mode 100644 packages/core/src/tooltip/positioner/TooltipPositionerDataAttributes.ts create mode 100644 packages/core/src/tooltip/provider/TooltipProvider.vue create mode 100644 packages/core/src/tooltip/provider/TooltipProviderContext.ts create mode 100644 packages/core/src/tooltip/root/TooltipRoot.test.ts create mode 100644 packages/core/src/tooltip/root/TooltipRoot.vue create mode 100644 packages/core/src/tooltip/root/TooltipRootContext.ts create mode 100644 packages/core/src/tooltip/store/TooltipHandle.ts create mode 100644 packages/core/src/tooltip/tooltip.types.ts create mode 100644 packages/core/src/tooltip/trigger/TooltipTrigger.vue create mode 100644 packages/core/src/tooltip/trigger/TooltipTriggerDataAttributes.ts create mode 100644 packages/core/src/tooltip/utils/constants.ts create mode 100644 packages/core/src/tooltip/utils/popupStateMapping.ts create mode 100644 packages/core/src/tooltip/viewport/TooltipViewport.vue create mode 100644 packages/core/src/tooltip/viewport/TooltipViewportCssVars.ts create mode 100644 packages/core/src/tooltip/viewport/TooltipViewportDataAttributes.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index bfe1be1..3f990bc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -98,6 +98,7 @@ export default defineConfig({ { text: 'Tabs', link: '/docs/components/tabs' }, { text: 'Toggle', link: '/docs/components/toggle' }, { text: 'Toggle Group', link: '/docs/components/toggle-group' }, + { text: 'Tooltip', link: '/docs/components/tooltip' }, { text: 'Toolbar', link: '/docs/components/toolbar' }, ], }, diff --git a/docs/components/demo/Tooltip/css/index.vue b/docs/components/demo/Tooltip/css/index.vue new file mode 100644 index 0000000..5dba8e7 --- /dev/null +++ b/docs/components/demo/Tooltip/css/index.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/docs/components/demo/Tooltip/css/styles.css b/docs/components/demo/Tooltip/css/styles.css new file mode 100644 index 0000000..39f6412 --- /dev/null +++ b/docs/components/demo/Tooltip/css/styles.css @@ -0,0 +1,121 @@ +.Panel { + display: flex; + border: 1px solid var(--color-gray-900); + background-color: var(--color-gray-50); +} + +.Button { + box-sizing: border-box; + display: flex; + width: 2rem; + height: 2rem; + align-items: center; + justify-content: center; + margin: 0; + border: 0; + background-color: transparent; + color: var(--color-gray-900); + -webkit-user-select: none; + user-select: none; +} + +.Button[data-popup-open] { + background-color: var(--color-gray-100); +} + +@media (hover: hover) { + .Button:hover:not([data-trigger-disabled]) { + background-color: var(--color-gray-100); + } +} + +.Button:active:not([data-trigger-disabled]) { + background-color: var(--color-gray-200); +} + +.Button:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; +} + +.Symbol { + font-size: 0.875rem; + font-weight: 700; + line-height: 1; +} + +.Italic { + font-style: italic; +} + +.Underline { + text-decoration: underline; + text-underline-offset: 0.125rem; +} + +.Popup { + box-sizing: border-box; + position: relative; + display: flex; + flex-direction: column; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-gray-900); + background-color: var(--color-gray-50); + color: var(--color-gray-900); + box-shadow: 0.25rem 0.25rem 0 rgb(0 0 0 / 12%); + font-size: 0.875rem; + line-height: 1.25rem; + transform-origin: var(--transform-origin); + transition: + scale 100ms ease-out, + opacity 100ms ease-out; +} + +.Popup[data-starting-style], +.Popup[data-ending-style] { + opacity: 0; + scale: 0.98; +} + +.Popup[data-instant] { + transition: none; +} + +.Arrow { + display: block; + width: 12px; + height: 6px; + overflow: clip; +} + +.Arrow[data-side='top'] { + bottom: -6px; + rotate: 180deg; +} + +.Arrow[data-side='bottom'] { + top: -6px; +} + +.Arrow[data-side='left'] { + right: -9px; + rotate: 90deg; +} + +.Arrow[data-side='right'] { + left: -9px; + rotate: -90deg; +} + +.Arrow::before { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + box-sizing: border-box; + width: calc(6px * sqrt(2)); + height: calc(6px * sqrt(2)); + border: 1px solid var(--color-gray-900); + background-color: var(--color-gray-50); + transform: translate(-50%, 50%) rotate(45deg); +} diff --git a/docs/components/demo/Tooltip/tailwind/index.vue b/docs/components/demo/Tooltip/tailwind/index.vue new file mode 100644 index 0000000..6f48b94 --- /dev/null +++ b/docs/components/demo/Tooltip/tailwind/index.vue @@ -0,0 +1,63 @@ + + + diff --git a/docs/components/demo/TooltipControlled/css/index.vue b/docs/components/demo/TooltipControlled/css/index.vue new file mode 100644 index 0000000..a74c45c --- /dev/null +++ b/docs/components/demo/TooltipControlled/css/index.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/docs/components/demo/TooltipControlled/css/styles.css b/docs/components/demo/TooltipControlled/css/styles.css new file mode 100644 index 0000000..3a3d435 --- /dev/null +++ b/docs/components/demo/TooltipControlled/css/styles.css @@ -0,0 +1,50 @@ +@import "../../TooltipDetached/css/styles.css"; + +.Container { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + +.ButtonGroup { + display: flex; +} + +.IconButton:not(:first-child) { + border-left: 0; +} + +.ActionButton { + box-sizing: border-box; + display: flex; + height: 2rem; + align-items: center; + justify-content: center; + margin: 0; + padding: 0 0.75rem; + border: 1px solid var(--color-gray-900); + background-color: var(--color-gray-50); + color: var(--color-gray-900); + font-family: inherit; + font-size: 0.875rem; + line-height: 1; + white-space: nowrap; + -webkit-user-select: none; + user-select: none; +} + +@media (hover: hover) { + .ActionButton:hover { + background-color: var(--color-gray-100); + } +} + +.ActionButton:active { + background-color: var(--color-gray-200); +} + +.ActionButton:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; +} diff --git a/docs/components/demo/TooltipControlled/tailwind/index.vue b/docs/components/demo/TooltipControlled/tailwind/index.vue new file mode 100644 index 0000000..f013100 --- /dev/null +++ b/docs/components/demo/TooltipControlled/tailwind/index.vue @@ -0,0 +1,73 @@ + + + diff --git a/docs/components/demo/TooltipDetached/css/index.vue b/docs/components/demo/TooltipDetached/css/index.vue new file mode 100644 index 0000000..bfc209f --- /dev/null +++ b/docs/components/demo/TooltipDetached/css/index.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/docs/components/demo/TooltipDetached/css/styles.css b/docs/components/demo/TooltipDetached/css/styles.css new file mode 100644 index 0000000..166edb4 --- /dev/null +++ b/docs/components/demo/TooltipDetached/css/styles.css @@ -0,0 +1,40 @@ +@import "../../Tooltip/css/styles.css"; + +.IconButton { + box-sizing: border-box; + display: flex; + width: 2rem; + height: 2rem; + align-items: center; + justify-content: center; + margin: 0; + border: 1px solid var(--color-gray-900); + background-color: var(--color-gray-50); + color: var(--color-gray-900); + -webkit-user-select: none; + user-select: none; +} + +.IconButton[data-popup-open] { + background-color: var(--color-gray-100); +} + +@media (hover: hover) { + .IconButton:hover:not([data-trigger-disabled]) { + background-color: var(--color-gray-100); + } +} + +.IconButton:active:not([data-trigger-disabled]) { + background-color: var(--color-gray-200); +} + +.IconButton:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -1px; +} + +.Icon { + width: 1rem; + height: 1rem; +} diff --git a/docs/components/demo/TooltipDetached/tailwind/index.vue b/docs/components/demo/TooltipDetached/tailwind/index.vue new file mode 100644 index 0000000..901340f --- /dev/null +++ b/docs/components/demo/TooltipDetached/tailwind/index.vue @@ -0,0 +1,40 @@ + + + diff --git a/docs/components/demo/TooltipMultiple/css/index.vue b/docs/components/demo/TooltipMultiple/css/index.vue new file mode 100644 index 0000000..1c7b66e --- /dev/null +++ b/docs/components/demo/TooltipMultiple/css/index.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/docs/components/demo/TooltipMultiple/css/styles.css b/docs/components/demo/TooltipMultiple/css/styles.css new file mode 100644 index 0000000..2ef5933 --- /dev/null +++ b/docs/components/demo/TooltipMultiple/css/styles.css @@ -0,0 +1,58 @@ +@import "../../TooltipDetached/css/styles.css"; + +.ButtonGroup { + display: flex; +} + +.IconButton:not(:first-child) { + border-left: 0; +} + +.Positioner { + width: var(--positioner-width); + height: var(--positioner-height); + max-width: var(--available-width); + transition: + top 350ms cubic-bezier(0.22, 1, 0.36, 1), + left 350ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.Positioner[data-instant] { + transition: none; +} + +.Popup { + width: var(--popup-width, auto); + max-width: min(24rem, var(--available-width)); + transition: + width 350ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 160ms ease-out, + scale 160ms ease-out; +} + +.Viewport { + --viewport-inline-padding: 0.5rem; + box-sizing: border-box; + width: 100%; + overflow: clip; +} + +.Viewport [data-current] { + width: max-content; + max-width: calc(min(24rem, var(--available-width)) - 2 * var(--viewport-inline-padding)); + translate: 0; + opacity: 1; + transition: + translate 350ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 175ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.Viewport[data-activation-direction='left'] [data-current][data-starting-style] { + translate: -50% 0; + opacity: 0; +} + +.Viewport[data-activation-direction='right'] [data-current][data-starting-style] { + translate: 50% 0; + opacity: 0; +} diff --git a/docs/components/demo/TooltipMultiple/tailwind/index.vue b/docs/components/demo/TooltipMultiple/tailwind/index.vue new file mode 100644 index 0000000..9c55c33 --- /dev/null +++ b/docs/components/demo/TooltipMultiple/tailwind/index.vue @@ -0,0 +1,62 @@ + + + diff --git a/docs/content/docs/components/tooltip.md b/docs/content/docs/components/tooltip.md new file mode 100644 index 0000000..b782418 --- /dev/null +++ b/docs/content/docs/components/tooltip.md @@ -0,0 +1,245 @@ +--- +title: Tooltip +description: A high-quality, unstyled Vue tooltip component that appears when an element is hovered or focused. +--- + +# Tooltip + +A popup that appears when an element is hovered or focused, showing a hint for sighted users. + + + +## Usage guidelines + +- **Prefer using tooltips as visual labels only**: Tooltips should provide supplementary clarity. Do not hide essential information in a tooltip. +- **Provide an accessible name for the trigger**: Tooltips are visual-only hints and are not a replacement for labels. Icon-only triggers should have an `aria-label` that closely matches the tooltip content. +- **Use Popover for interactive content**: If the popup needs links, buttons, forms, or touch-friendly access, use a Popover-style component instead of Tooltip. + +## Anatomy + +Import the component parts and assemble them: + +```vue title="Anatomy" + + + +``` + +## Examples + +### Detached trigger + +Use `createTooltipHandle()` when the trigger and tooltip root are defined in different places. + +```vue title="Detached trigger" + + + +``` + + + +### Multiple triggers + +One tooltip can be shared by multiple triggers. Pass `payload` to a trigger and read it from the root scoped slot. + +```vue title="Payload scoped slot" + + Archive + + + + + {{ payload?.label }} + + +``` + + + +### Controlled state + +Use the `open` prop and `open-change` event to control visibility. When a tooltip has multiple triggers, use `trigger-id` on `TooltipRoot` and `id` on `TooltipTrigger`. + + + +## API reference + +### Provider + +Provides shared delays for a group of tooltips. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `delay` | `number` | `600` | How long to wait before opening a tooltip, in milliseconds. | +| `close-delay` | `number` | `0` | How long to wait before closing a tooltip, in milliseconds. | +| `timeout` | `number` | `400` | Another tooltip opens instantly if the previous tooltip closed within this timeout. | + +### Root + +Groups all parts of a tooltip. Does not render an element. + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `open` | `boolean` | `undefined` | Whether the tooltip is currently open. | +| `default-open` | `boolean` | `false` | Whether the tooltip is initially open. | +| `disabled` | `boolean` | `false` | Whether the tooltip should ignore interaction. | +| `disable-hoverable-popup` | `boolean` | `false` | Whether hovering the popup should keep it open. | +| `track-cursor-axis` | `'none' \| 'x' \| 'y' \| 'both'` | `'none'` | Which cursor axis the tooltip should track. | +| `handle` | `TooltipHandle` | `undefined` | A handle that links detached triggers to the root. | +| `trigger-id` | `string \| null` | `undefined` | The active trigger id in controlled mode. | +| `default-trigger-id` | `string \| null` | `null` | The active trigger id for an initially open uncontrolled tooltip. | + +| Emits | Type | Description | +| --- | --- | --- | +| `open-change` | `(open: boolean, details: EventDetails) => void` | Emitted when the tooltip requests to open or close. Call `details.cancel()` to prevent the internal change. | +| `open-change-complete` | `(open: boolean) => void` | Emitted after the popup's open or close animation completes. | + +### Trigger + +The element that opens the tooltip. Renders a ` + `, + })) + + await user.click(screen.getByRole('button', { name: 'Open externally' })) + await nextTick() + + expect(screen.getByText(CONTENT)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'One' })).toHaveAttribute('data-popup-open') + }) + + it('supports detached triggers and payload scoped slots', async () => { + const handle = createTooltipHandle<{ label: string }>() + + render(defineComponent({ + components: { + TooltipPopup, + TooltipPortal, + TooltipPositioner, + TooltipRoot, + TooltipTrigger, + }, + setup() { + return { handle } + }, + template: ` + + First + + + Second + + + + + + {{ payload ? payload.label : '' }} + + + + + `, + })) + + await fireEvent.mouseEnter(screen.getByRole('button', { name: 'First' })) + await nextTick() + expect(screen.getByRole('tooltip')).toHaveTextContent('First trigger') + + await fireEvent.mouseLeave(screen.getByRole('button', { name: 'First' })) + await nextTick() + await nextTick() + + await fireEvent.mouseEnter(screen.getByRole('button', { name: 'Second' })) + await nextTick() + expect(screen.getByRole('tooltip')).toHaveTextContent('Second trigger') + }) +}) diff --git a/packages/core/src/tooltip/root/TooltipRoot.vue b/packages/core/src/tooltip/root/TooltipRoot.vue new file mode 100644 index 0000000..080af02 --- /dev/null +++ b/packages/core/src/tooltip/root/TooltipRoot.vue @@ -0,0 +1,333 @@ + + + diff --git a/packages/core/src/tooltip/root/TooltipRootContext.ts b/packages/core/src/tooltip/root/TooltipRootContext.ts new file mode 100644 index 0000000..46d5e03 --- /dev/null +++ b/packages/core/src/tooltip/root/TooltipRootContext.ts @@ -0,0 +1,89 @@ +import type { + ComputedRef, + InjectionKey, + Ref, + ShallowRef, +} from 'vue' +import type { TransitionStatus } from '../../utils/useTransitionStatus' +import type { TooltipStore, TooltipTriggerRecord } from '../store/TooltipHandle' +import type { + TooltipAlign, + TooltipInstantType, + TooltipRootChangeEventDetails, + TooltipSide, + TooltipTrackCursorAxis, +} from '../tooltip.types' +import { inject } from 'vue' + +export interface TooltipRootContext { + store: TooltipStore + open: Readonly> + mounted: Ref + transitionStatus: Ref + disabled: Readonly> + disableHoverablePopup: Readonly> + trackCursorAxis: Readonly> + instantType: Ref + activeTriggerId: Ref + activeTrigger: ComputedRef | undefined> + payload: ComputedRef + popupId: Ref + popupRef: Ref + positionerRef: Ref + arrowRef: Ref + side: Ref + align: Ref + anchorHidden: Ref + arrowX: ShallowRef + arrowY: ShallowRef + arrowUncentered: Ref + positionerWidth: ShallowRef + positionerHeight: ShallowRef + availableWidth: ShallowRef + availableHeight: ShallowRef + anchorWidth: ShallowRef + anchorHeight: ShallowRef + popupWidth: ShallowRef + popupHeight: ShallowRef + requestOpenChange: ( + nextOpen: boolean, + details: TooltipRootChangeEventDetails, + triggerId?: string | null, + ) => void + getOpenDelay: (triggerDelay: number | undefined) => { delay: number, instant: boolean } + getCloseDelay: (triggerCloseDelay: number | undefined) => number + scheduleClose: ( + delay: number, + details: TooltipRootChangeEventDetails, + triggerId?: string | null, + ) => void + clearCloseTimer: () => void + completeOpenChange: () => void + setMounted: (next: boolean) => void + setPopupId: (id: string | undefined) => void + setPositionerSize: (size: { + width?: number + height?: number + availableWidth?: number + availableHeight?: number + anchorWidth?: number + anchorHeight?: number + }) => void +} + +export const tooltipRootContextKey: InjectionKey + = Symbol('TooltipRootContext') + +export function useTooltipRootContext(optional?: false): TooltipRootContext +export function useTooltipRootContext(optional: true): TooltipRootContext | undefined +export function useTooltipRootContext(optional = false): TooltipRootContext | undefined { + const context = inject(tooltipRootContextKey, undefined) + + if (context === undefined && !optional) { + throw new Error( + 'Base UI Vue: TooltipRootContext is missing. Tooltip parts must be placed within or receive a tooltip handle.', + ) + } + + return context +} diff --git a/packages/core/src/tooltip/store/TooltipHandle.ts b/packages/core/src/tooltip/store/TooltipHandle.ts new file mode 100644 index 0000000..17f247e --- /dev/null +++ b/packages/core/src/tooltip/store/TooltipHandle.ts @@ -0,0 +1,142 @@ +import type { Ref, ShallowRef } from 'vue' +import type { + TooltipInstantType, + TooltipRootChangeEventDetails, + TooltipRootChangeEventReason, +} from '../tooltip.types' +import { shallowRef } from 'vue' +import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails' +import { REASONS } from '../../utils/reasons' + +export interface TooltipTriggerRecord { + id: string + element: HTMLElement + payload: Payload | undefined + closeOnClick: boolean + closeDelay: number | undefined +} + +export interface TooltipRootController { + open: Readonly> + activeTriggerId: Ref + disabled: Readonly> + instantType: Ref + popupId: Ref + requestOpenChange: ( + nextOpen: boolean, + details: TooltipRootChangeEventDetails, + triggerId?: string | null, + ) => void + getOpenDelay: (triggerDelay: number | undefined) => { delay: number, instant: boolean } + getCloseDelay: (triggerCloseDelay: number | undefined) => number + scheduleClose: ( + delay: number, + details: TooltipRootChangeEventDetails, + triggerId?: string | null, + ) => void + clearCloseTimer: () => void + forceUnmount: () => void + maybeActivateTrigger: (id: string) => void +} + +export class TooltipStore { + readonly controller: ShallowRef = shallowRef(null) + + private readonly triggers = new Map>() + + readonly version = shallowRef(0) + + registerTrigger(record: TooltipTriggerRecord) { + this.triggers.set(record.id, record) + this.version.value += 1 + this.controller.value?.maybeActivateTrigger(record.id) + } + + unregisterTrigger(id: string, element: HTMLElement) { + const existing = this.triggers.get(id) + + if (existing?.element !== element) { + return + } + + this.triggers.delete(id) + this.version.value += 1 + } + + getTrigger(id: string | null | undefined): TooltipTriggerRecord | undefined { + // Keep Map mutations visible to Vue computed refs that read trigger data. + void this.version.value + + if (id == null) { + return undefined + } + + return this.triggers.get(id) + } + + open(triggerId: string) { + const trigger = this.getTrigger(triggerId) + + if (!trigger) { + throw new Error(`Base UI Vue: TooltipHandle.open: No trigger found with id "${triggerId}".`) + } + + this.controller.value?.requestOpenChange( + true, + createTooltipChangeEventDetails(REASONS.imperativeAction, undefined, trigger.element), + triggerId, + ) + } + + close() { + this.controller.value?.requestOpenChange( + false, + createTooltipChangeEventDetails(REASONS.imperativeAction), + ) + } + + get isOpen() { + return this.controller.value?.open.value ?? false + } +} + +export function createTooltipHandle(): TooltipHandle { + return new TooltipHandle() +} + +/** + * A handle to control a tooltip imperatively and to associate detached triggers with it. + */ +export class TooltipHandle { + readonly store = new TooltipStore() + + open(triggerId: string) { + this.store.open(triggerId) + } + + close() { + this.store.close() + } + + get isOpen() { + return this.store.isOpen + } +} + +export function createTooltipChangeEventDetails( + reason: TooltipRootChangeEventReason, + event?: any, + trigger?: HTMLElement, +): TooltipRootChangeEventDetails { + return createChangeEventDetails< + TooltipRootChangeEventReason, + { preventUnmountOnClose: () => void } + >( + reason, + event, + trigger, + { + preventUnmountOnClose() {}, + }, + ) as TooltipRootChangeEventDetails +} diff --git a/packages/core/src/tooltip/tooltip.types.ts b/packages/core/src/tooltip/tooltip.types.ts new file mode 100644 index 0000000..33e7f22 --- /dev/null +++ b/packages/core/src/tooltip/tooltip.types.ts @@ -0,0 +1,217 @@ +import type { + Boundary, + Padding, + Placement, + Strategy, + VirtualElement, +} from '@floating-ui/vue' +import type { BaseUIChangeEventDetails } from '../utils/createBaseUIEventDetails' +import type { REASONS } from '../utils/reasons' +import type { BaseUIComponentProps, NativeButtonProps } from '../utils/types' +import type { TransitionStatus } from '../utils/useTransitionStatus' +import type { TooltipHandle } from './store/TooltipHandle' + +export type TooltipSide = 'top' | 'right' | 'bottom' | 'left' +export type TooltipAlign = 'start' | 'center' | 'end' +export type TooltipInstantType = 'delay' | 'dismiss' | 'focus' | 'tracking-cursor' | undefined +export type TooltipTrackCursorAxis = 'none' | 'x' | 'y' | 'both' + +export interface TooltipRootState {} + +export type TooltipRootChangeEventReason + = | typeof REASONS.triggerHover + | typeof REASONS.triggerFocus + | typeof REASONS.triggerPress + | typeof REASONS.outsidePress + | typeof REASONS.escapeKey + | typeof REASONS.disabled + | typeof REASONS.imperativeAction + | typeof REASONS.none + +export type TooltipRootChangeEventDetails + = BaseUIChangeEventDetails void + }> + +export interface TooltipRootActions { + close: () => void + unmount: () => void +} + +export interface TooltipRootProps + extends BaseUIComponentProps { + /** + * Whether the tooltip is initially open. + * To render a controlled tooltip, use the `open` prop instead. + * @default false + */ + defaultOpen?: boolean + /** + * Whether the tooltip is currently open. + */ + open?: boolean + /** + * Whether the tooltip contents can be hovered without closing the tooltip. + * @default false + */ + disableHoverablePopup?: boolean + /** + * Determines which axis the tooltip should track the cursor on. + * @default 'none' + */ + trackCursorAxis?: TooltipTrackCursorAxis + /** + * Whether the tooltip is disabled. + * @default false + */ + disabled?: boolean + /** + * A handle to associate the tooltip with detached triggers. + */ + handle?: TooltipHandle + /** + * ID of the trigger that the tooltip is associated with in controlled mode. + */ + triggerId?: string | null + /** + * ID of the trigger that the tooltip is associated with when initially open. + */ + defaultTriggerId?: string | null +} + +export interface TooltipProviderProps { + /** + * How long to wait before opening a tooltip, in milliseconds. + */ + delay?: number + /** + * How long to wait before closing a tooltip, in milliseconds. + */ + closeDelay?: number + /** + * Another tooltip opens instantly if the previous tooltip closed within this timeout. + * @default 400 + */ + timeout?: number +} + +export interface TooltipTriggerState { + /** + * Whether the tooltip associated with this trigger is open. + */ + open: boolean + /** + * Whether this trigger should ignore interaction. + */ + disabled: boolean +} + +export interface TooltipTriggerProps + extends NativeButtonProps, BaseUIComponentProps { + /** + * A handle to associate this trigger with a tooltip. + */ + handle?: TooltipHandle + /** + * A payload exposed to the root scoped slot when this trigger opens the tooltip. + */ + payload?: Payload + /** + * How long to wait before opening the tooltip, in milliseconds. + * @default 600 + */ + delay?: number + /** + * Whether the tooltip should close when this trigger is clicked. + * @default true + */ + closeOnClick?: boolean + /** + * How long to wait before closing the tooltip, in milliseconds. + * @default 0 + */ + closeDelay?: number + /** + * If true, the tooltip will not open when interacting with this trigger. + * This does not apply the native `disabled` attribute. + * @default false + */ + disabled?: boolean + /** + * The trigger id. + */ + id?: string +} + +export interface TooltipPortalProps { + /** + * Whether to keep the portal mounted while the tooltip is hidden. + * @default false + */ + keepMounted?: boolean + /** + * Teleport target. + * @default 'body' + */ + to?: string | HTMLElement + /** + * Disables Vue Teleport while preserving tooltip mounting behavior. + * @default false + */ + disabled?: boolean +} + +export interface TooltipPositionerState { + open: boolean + side: TooltipSide + align: TooltipAlign + anchorHidden: boolean + instant: TooltipInstantType +} + +export interface TooltipPositionerProps + extends BaseUIComponentProps { + anchor?: HTMLElement | VirtualElement | null + positionMethod?: Strategy + side?: TooltipSide + align?: TooltipAlign + sideOffset?: number + alignOffset?: number + collisionBoundary?: Boundary + collisionPadding?: Padding + arrowPadding?: Padding + sticky?: boolean + disableAnchorTracking?: boolean +} + +export interface TooltipPopupState { + open: boolean + side: TooltipSide + align: TooltipAlign + instant: TooltipInstantType + transitionStatus: TransitionStatus +} + +export interface TooltipPopupProps extends BaseUIComponentProps { + id?: string +} + +export interface TooltipArrowState { + open: boolean + side: TooltipSide + align: TooltipAlign + uncentered: boolean + instant: TooltipInstantType +} + +export interface TooltipArrowProps extends BaseUIComponentProps {} + +export interface TooltipViewportState { + activationDirection: 'left' | 'right' | 'up' | 'down' | undefined + transitioning: boolean + instant: TooltipInstantType +} + +export interface TooltipViewportProps extends BaseUIComponentProps {} + +export type TooltipFloatingPlacement = Placement diff --git a/packages/core/src/tooltip/trigger/TooltipTrigger.vue b/packages/core/src/tooltip/trigger/TooltipTrigger.vue new file mode 100644 index 0000000..36f6867 --- /dev/null +++ b/packages/core/src/tooltip/trigger/TooltipTrigger.vue @@ -0,0 +1,268 @@ + + + diff --git a/packages/core/src/tooltip/trigger/TooltipTriggerDataAttributes.ts b/packages/core/src/tooltip/trigger/TooltipTriggerDataAttributes.ts new file mode 100644 index 0000000..5cc9bae --- /dev/null +++ b/packages/core/src/tooltip/trigger/TooltipTriggerDataAttributes.ts @@ -0,0 +1,10 @@ +export enum TooltipTriggerDataAttributes { + /** + * Present when the corresponding tooltip is open. + */ + popupOpen = 'data-popup-open', + /** + * Present when the trigger is disabled, either by the `disabled` prop or by a parent ``. + */ + triggerDisabled = 'data-trigger-disabled', +} diff --git a/packages/core/src/tooltip/utils/constants.ts b/packages/core/src/tooltip/utils/constants.ts new file mode 100644 index 0000000..e999b65 --- /dev/null +++ b/packages/core/src/tooltip/utils/constants.ts @@ -0,0 +1 @@ +export const OPEN_DELAY = 600 diff --git a/packages/core/src/tooltip/utils/popupStateMapping.ts b/packages/core/src/tooltip/utils/popupStateMapping.ts new file mode 100644 index 0000000..050e48b --- /dev/null +++ b/packages/core/src/tooltip/utils/popupStateMapping.ts @@ -0,0 +1,99 @@ +import type { StateAttributesMapping } from '../../utils/getStateAttributesProps' + +export enum CommonPopupDataAttributes { + /** + * Present when the popup is open. + */ + open = 'data-open', + /** + * Present when the popup is closed. + */ + closed = 'data-closed', + /** + * Present when the popup is animating in. + */ + startingStyle = 'data-starting-style', + /** + * Present when the popup is animating out. + */ + endingStyle = 'data-ending-style', + /** + * Present when the anchor is hidden. + */ + anchorHidden = 'data-anchor-hidden', + /** + * Indicates which side the popup is positioned relative to the trigger. + */ + side = 'data-side', + /** + * Indicates how the popup is aligned relative to the specified side. + */ + align = 'data-align', +} + +export enum CommonTriggerDataAttributes { + /** + * Present when the popup is open. + */ + popupOpen = 'data-popup-open', + /** + * Present when the trigger is disabled. + */ + triggerDisabled = 'data-trigger-disabled', +} + +const POPUP_OPEN_HOOK = { + [CommonPopupDataAttributes.open]: '', +} + +const POPUP_CLOSED_HOOK = { + [CommonPopupDataAttributes.closed]: '', +} + +const ANCHOR_HIDDEN_HOOK = { + [CommonPopupDataAttributes.anchorHidden]: '', +} + +const TRIGGER_OPEN_HOOK = { + [CommonTriggerDataAttributes.popupOpen]: '', +} + +const TRIGGER_DISABLED_HOOK = { + [CommonTriggerDataAttributes.triggerDisabled]: '', +} + +export const popupStateMapping = { + open(value) { + return value ? POPUP_OPEN_HOOK : POPUP_CLOSED_HOOK + }, + anchorHidden(value) { + return value ? ANCHOR_HIDDEN_HOOK : null + }, + side(value) { + return value ? { [CommonPopupDataAttributes.side]: value } : null + }, + align(value) { + return value ? { [CommonPopupDataAttributes.align]: value } : null + }, + instant(value) { + return value ? { 'data-instant': value } : null + }, +} satisfies StateAttributesMapping<{ + open: boolean + anchorHidden?: boolean + side?: string + align?: string + instant?: string +}> + +export const triggerStateMapping = { + open(value) { + return value ? TRIGGER_OPEN_HOOK : null + }, + disabled(value) { + return value ? TRIGGER_DISABLED_HOOK : null + }, +} satisfies StateAttributesMapping<{ + open: boolean + disabled: boolean +}> diff --git a/packages/core/src/tooltip/viewport/TooltipViewport.vue b/packages/core/src/tooltip/viewport/TooltipViewport.vue new file mode 100644 index 0000000..3e1f229 --- /dev/null +++ b/packages/core/src/tooltip/viewport/TooltipViewport.vue @@ -0,0 +1,97 @@ + + + diff --git a/packages/core/src/tooltip/viewport/TooltipViewportCssVars.ts b/packages/core/src/tooltip/viewport/TooltipViewportCssVars.ts new file mode 100644 index 0000000..e22b1f0 --- /dev/null +++ b/packages/core/src/tooltip/viewport/TooltipViewportCssVars.ts @@ -0,0 +1,4 @@ +export enum TooltipViewportCssVars { + width = '--tooltip-viewport-width', + height = '--tooltip-viewport-height', +} diff --git a/packages/core/src/tooltip/viewport/TooltipViewportDataAttributes.ts b/packages/core/src/tooltip/viewport/TooltipViewportDataAttributes.ts new file mode 100644 index 0000000..123c298 --- /dev/null +++ b/packages/core/src/tooltip/viewport/TooltipViewportDataAttributes.ts @@ -0,0 +1,5 @@ +export enum TooltipViewportDataAttributes { + activationDirection = 'data-activation-direction', + transitioning = 'data-transitioning', + instant = 'data-instant', +} From 3f58aff4f446192e335520e0a2b126eae6a059d6 Mon Sep 17 00:00:00 2001 From: peoray Date: Fri, 12 Jun 2026 18:24:37 +0100 Subject: [PATCH 2/8] feat: enhance tooltip components with type exports and context updates --- docs/.vitepress/config.ts | 2 +- eslint.config.mjs | 5 + packages/core/src/floating-ui-vue/index.ts | 18 + .../src/floating-ui-vue/middleware/arrow.ts | 110 +++ .../src/tooltip/arrow/TooltipArrow.test.ts | 326 +++++++++ .../core/src/tooltip/arrow/TooltipArrow.vue | 55 +- .../arrow/TooltipArrowDataAttributes.ts | 31 +- packages/core/src/tooltip/index.ts | 24 +- .../core/src/tooltip/popup/TooltipPopup.vue | 75 +- .../popup/TooltipPopupDataAttributes.ts | 38 +- .../core/src/tooltip/portal/TooltipPortal.vue | 23 +- .../positioner/TooltipPositioner.test.ts | 176 +++++ .../tooltip/positioner/TooltipPositioner.vue | 290 ++++---- .../positioner/TooltipPositionerContext.ts | 16 +- .../src/tooltip/provider/TooltipProvider.vue | 22 +- .../core/src/tooltip/root/TooltipRoot.vue | 133 ++-- .../src/tooltip/root/TooltipRootContext.ts | 27 +- .../core/src/tooltip/store/TooltipHandle.ts | 2 +- packages/core/src/tooltip/tooltip.types.ts | 217 ------ .../src/tooltip/trigger/TooltipTrigger.vue | 56 +- .../src/tooltip/viewport/TooltipViewport.vue | 32 +- .../src/utils/adaptiveOriginMiddleware.ts | 79 +++ packages/core/src/utils/hideMiddleware.ts | 19 + .../{tooltip => }/utils/popupStateMapping.ts | 2 +- .../core/src/utils/useAnchorPositioning.ts | 639 ++++++++++++++++++ 25 files changed, 1928 insertions(+), 489 deletions(-) create mode 100644 packages/core/src/floating-ui-vue/index.ts create mode 100644 packages/core/src/floating-ui-vue/middleware/arrow.ts create mode 100644 packages/core/src/tooltip/arrow/TooltipArrow.test.ts create mode 100644 packages/core/src/tooltip/positioner/TooltipPositioner.test.ts delete mode 100644 packages/core/src/tooltip/tooltip.types.ts create mode 100644 packages/core/src/utils/adaptiveOriginMiddleware.ts create mode 100644 packages/core/src/utils/hideMiddleware.ts rename packages/core/src/{tooltip => }/utils/popupStateMapping.ts (96%) create mode 100644 packages/core/src/utils/useAnchorPositioning.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3f990bc..86d263a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -98,8 +98,8 @@ export default defineConfig({ { text: 'Tabs', link: '/docs/components/tabs' }, { text: 'Toggle', link: '/docs/components/toggle' }, { text: 'Toggle Group', link: '/docs/components/toggle-group' }, - { text: 'Tooltip', link: '/docs/components/tooltip' }, { text: 'Toolbar', link: '/docs/components/toolbar' }, + { text: 'Tooltip', link: '/docs/components/tooltip' }, ], }, { diff --git a/eslint.config.mjs b/eslint.config.mjs index fed9db7..1a39dd6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,4 +8,9 @@ export default antfu( '.agents/**', ], }, + { + rules: { + 'ts/prefer-literal-enum-member': 'off', + }, + }, ) diff --git a/packages/core/src/floating-ui-vue/index.ts b/packages/core/src/floating-ui-vue/index.ts new file mode 100644 index 0000000..673d966 --- /dev/null +++ b/packages/core/src/floating-ui-vue/index.ts @@ -0,0 +1,18 @@ +export { + arrow, + autoPlacement, + autoUpdate, + computePosition, + detectOverflow, + flip, + getOverflowAncestors, + hide, + inline, + limitShift, + offset, + platform, + shift, + size, + useFloating, +} from '@floating-ui/vue' +export type * from '@floating-ui/vue' diff --git a/packages/core/src/floating-ui-vue/middleware/arrow.ts b/packages/core/src/floating-ui-vue/middleware/arrow.ts new file mode 100644 index 0000000..ca2bcce --- /dev/null +++ b/packages/core/src/floating-ui-vue/middleware/arrow.ts @@ -0,0 +1,110 @@ +import type { Middleware, MiddlewareState, Padding } from '@floating-ui/vue' +import { + clamp, + evaluate, + getAlignment, + getAlignmentAxis, + getAxisLength, + getPaddingObject, +} from '@floating-ui/utils' + +type Derivable = T | ((state: MiddlewareState) => T) + +export interface ArrowOptions { + /** + * The arrow element to be positioned. + * @default undefined + */ + element: Element | null | undefined + /** + * The padding between the arrow element and the floating element edges. + * Useful when the floating element has rounded corners. + * @default 0 + */ + padding?: Padding + /** + * Which element to use as the offset parent. + * @default 'real' + */ + offsetParent: 'real' | 'floating' +} + +/** + * Fork of the original `arrow` middleware from Floating UI that allows + * configuring the offset parent. + */ +export function baseArrow(options: ArrowOptions | Derivable): Middleware { + return { + name: 'arrow', + options, + async fn(state) { + const { x, y, placement, rects, platform, elements, middlewareData } = state + const { element, padding = 0, offsetParent = 'real' } = evaluate(options, state) || {} + + if (element == null) { + return {} + } + + const paddingObject = getPaddingObject(padding) + const coords = { x, y } + const axis = getAlignmentAxis(placement) + const length = getAxisLength(axis) + const arrowDimensions = await platform.getDimensions(element) + const isYAxis = axis === 'y' + const minProp = isYAxis ? 'top' : 'left' + const maxProp = isYAxis ? 'bottom' : 'right' + const clientProp = isYAxis ? 'clientHeight' : 'clientWidth' + + const endDiff + = rects.reference[length] + rects.reference[axis] - coords[axis] - rects.floating[length] + const startDiff = coords[axis] - rects.reference[axis] + + const arrowOffsetParent = offsetParent === 'real' + ? await platform.getOffsetParent?.(element) + : elements.floating + let clientSize = elements.floating[clientProp] || rects.floating[length] + + if (!clientSize || !(await platform.isElement?.(arrowOffsetParent))) { + clientSize = elements.floating[clientProp] || rects.floating[length] + } + + const centerToReference = endDiff / 2 - startDiff / 2 + const largestPossiblePadding = clientSize / 2 - arrowDimensions[length] / 2 - 1 + const minPadding = Math.min(paddingObject[minProp], largestPossiblePadding) + const maxPadding = Math.min(paddingObject[maxProp], largestPossiblePadding) + + const min = minPadding + const max = clientSize - arrowDimensions[length] - maxPadding + const center = clientSize / 2 - arrowDimensions[length] / 2 + centerToReference + const offset = clamp(min, center, max) + + const shouldAddOffset + = !middlewareData.arrow + && getAlignment(placement) != null + && center !== offset + && rects.reference[length] / 2 + - (center < min ? minPadding : maxPadding) + - arrowDimensions[length] / 2 + < 0 + const alignmentOffset = shouldAddOffset ? (center < min ? center - min : center - max) : 0 + + return { + [axis]: coords[axis] + alignmentOffset, + data: { + [axis]: offset, + centerOffset: center - offset - alignmentOffset, + ...(shouldAddOffset && { alignmentOffset }), + }, + reset: shouldAddOffset, + } + }, + } +} + +/** + * Provides data to position an inner element of the floating element so that it + * appears centered to the reference element. + */ +export function arrow(options: ArrowOptions | Derivable): Middleware { + return baseArrow(options) +} diff --git a/packages/core/src/tooltip/arrow/TooltipArrow.test.ts b/packages/core/src/tooltip/arrow/TooltipArrow.test.ts new file mode 100644 index 0000000..9a64b7d --- /dev/null +++ b/packages/core/src/tooltip/arrow/TooltipArrow.test.ts @@ -0,0 +1,326 @@ +import type { Component } from 'vue' +import type { Align, Side } from '../../utils/useAnchorPositioning' +import type { TooltipPositionerContext } from '../positioner/TooltipPositionerContext' +import type { TooltipInstantType, TooltipTrackCursorAxis } from '../root/TooltipRoot.vue' +import type { TooltipRootContext } from '../root/TooltipRootContext' +import { render, screen } from '@testing-library/vue' +import { describe, expect, it, vi } from 'vitest' +import { computed, defineComponent, h, provide, shallowRef } from 'vue' +import { Slot } from '../../utils/slot' +import TooltipPortal from '../portal/TooltipPortal.vue' +import TooltipPositioner from '../positioner/TooltipPositioner.vue' +import { tooltipPositionerContextKey } from '../positioner/TooltipPositionerContext' +import TooltipRoot from '../root/TooltipRoot.vue' +import { tooltipRootContextKey } from '../root/TooltipRootContext' +import { TooltipStore } from '../store/TooltipHandle' +import TooltipArrow from './TooltipArrow.vue' + +interface TooltipArrowTestOptions { + open?: boolean + instant?: TooltipInstantType + positionerSide?: Side + positionerAlign?: Align + arrowX?: number + arrowY?: number + uncentered?: boolean + trackCursorAxis?: TooltipTrackCursorAxis + props?: Record +} + +function createTooltipContexts(options: TooltipArrowTestOptions = {}) { + const rootArrowRef = shallowRef(null) + const positionerArrowRef = shallowRef(null) + const positionerSide = shallowRef(options.positionerSide ?? 'top') + const positionerAlign = shallowRef(options.positionerAlign ?? 'center') + const arrowUncentered = shallowRef(options.uncentered ?? false) + + const root = { + store: new TooltipStore(), + open: shallowRef(options.open ?? true), + mounted: shallowRef(true), + transitionStatus: shallowRef('idle'), + disabled: shallowRef(false), + disableHoverablePopup: shallowRef(false), + trackCursorAxis: shallowRef(options.trackCursorAxis ?? 'none'), + hasViewport: shallowRef(false), + instantType: shallowRef(options.instant), + activeTriggerId: shallowRef(null), + activeTrigger: computed(() => undefined), + payload: computed(() => undefined), + popupId: shallowRef(undefined), + popupRef: shallowRef(null), + positionerRef: shallowRef(null), + popupWidth: shallowRef(undefined), + popupHeight: shallowRef(undefined), + requestOpenChange: vi.fn(), + getOpenDelay: vi.fn(() => ({ delay: 0, instant: false })), + getCloseDelay: vi.fn(() => 0), + scheduleClose: vi.fn(), + clearCloseTimer: vi.fn(), + completeOpenChange: vi.fn(), + setMounted: vi.fn(), + setPopupId: vi.fn(), + setHasViewport: vi.fn(), + } as unknown as TooltipRootContext + + const positioner: TooltipPositionerContext = { + side: computed(() => positionerSide.value), + align: computed(() => positionerAlign.value), + arrowRef: positionerArrowRef, + arrowStyles: computed(() => ({ + position: 'absolute', + left: options.arrowX == null ? undefined : `${options.arrowX}px`, + top: options.arrowY == null ? undefined : `${options.arrowY}px`, + })), + arrowUncentered: computed(() => arrowUncentered.value), + } + + return { + root, + positioner, + rootArrowRef, + positionerArrowRef, + } +} + +function renderTooltipArrow(options: TooltipArrowTestOptions = {}) { + const contexts = createTooltipContexts(options) + + const TestComponent = defineComponent({ + components: { TooltipArrow }, + setup() { + provide(tooltipRootContextKey, contexts.root) + provide(tooltipPositionerContextKey, contexts.positioner) + + return { + arrowProps: options.props ?? {}, + } + }, + template: ` + + Arrow + + `, + }) + + return { + ...render(TestComponent), + ...contexts, + } +} + +describe('', () => { + describe('base UI component API', () => { + describe('ref', () => { + it('attaches the ref', () => { + const { positionerArrowRef, rootArrowRef } = renderTooltipArrow() + const arrow = screen.getByTestId('arrow') + + expect(positionerArrowRef.value).toBe(arrow) + expect(rootArrowRef.value).toBeNull() + }) + }) + + describe('prop forwarding', () => { + it('forwards custom props to the default element', () => { + renderTooltipArrow({ + props: { + 'lang': 'fr', + 'data-foobar': 'custom-value', + }, + }) + + const arrow = screen.getByTestId('arrow') + expect(arrow).toHaveAttribute('lang', 'fr') + expect(arrow).toHaveAttribute('data-foobar', 'custom-value') + }) + + it('forwards the custom `style` attribute defined on the component', () => { + renderTooltipArrow({ + props: { + style: { + color: 'green', + }, + }, + }) + + expect(screen.getByTestId('arrow')).toHaveStyle({ + color: 'rgb(0, 128, 0)', + }) + }) + }) + + describe('prop: className', () => { + it('should apply the className when passed as a string', () => { + renderTooltipArrow({ + props: { + class: 'test-class', + }, + }) + + expect(screen.getByTestId('arrow')).toHaveClass('test-class') + }) + }) + }) + + it('renders a div by default', () => { + renderTooltipArrow() + + expect(screen.getByTestId('arrow').tagName).toBe('DIV') + }) + + it('uses the as prop instead of the default tag', () => { + renderTooltipArrow({ + props: { + as: 'span', + }, + }) + + expect(screen.getByTestId('arrow').tagName).toBe('SPAN') + }) + + it('renders a customized root element with a component', () => { + const CustomRoot = defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('section', attrs, slots.default?.()) + }, + }) + + renderTooltipArrow({ + props: { + as: CustomRoot as Component, + }, + }) + + expect(screen.getByTestId('arrow').tagName).toBe('SECTION') + }) + + it('uses the TooltipPositioner context for placement state', () => { + renderTooltipArrow({ + positionerSide: 'bottom', + positionerAlign: 'end', + }) + + const arrow = screen.getByTestId('arrow') + expect(arrow).toHaveAttribute('data-side', 'bottom') + expect(arrow).toHaveAttribute('data-align', 'end') + }) + + it('applies state attributes and positioning styles', () => { + renderTooltipArrow({ + arrowX: 12, + arrowY: 8, + instant: 'delay', + positionerSide: 'right', + positionerAlign: 'start', + uncentered: true, + }) + + const arrow = screen.getByTestId('arrow') + expect(arrow).toHaveAttribute('aria-hidden', 'true') + expect(arrow).toHaveAttribute('data-open') + expect(arrow).toHaveAttribute('data-side', 'right') + expect(arrow).toHaveAttribute('data-align', 'start') + expect(arrow).toHaveAttribute('data-uncentered') + expect(arrow).toHaveAttribute('data-instant', 'delay') + expect(arrow).toHaveStyle({ + left: '12px', + position: 'absolute', + top: '8px', + }) + }) + + it('applies closed state attributes', () => { + renderTooltipArrow({ + open: false, + }) + + const arrow = screen.getByTestId('arrow') + expect(arrow).toHaveAttribute('data-closed') + expect(arrow).not.toHaveAttribute('data-open') + }) + + it('omits data-uncentered when the arrow is centered', () => { + renderTooltipArrow({ + uncentered: false, + }) + + expect(screen.getByTestId('arrow')).not.toHaveAttribute('data-uncentered') + }) + + it('supports renderless mode and forwards the internal ref callback', () => { + const contexts = createTooltipContexts({ + positionerSide: 'right', + uncentered: true, + }) + + const TestComponent = defineComponent({ + components: { TooltipArrow }, + setup() { + provide(tooltipRootContextKey, contexts.root) + provide(tooltipPositionerContextKey, contexts.positioner) + + return { Slot } + }, + template: ` + + + {{ state.side }} {{ state.uncentered ? 'uncentered' : 'centered' }} + + + `, + }) + + render(TestComponent) + + const arrow = screen.getByTestId('renderless-arrow') + expect(arrow).toHaveAttribute('aria-hidden', 'true') + expect(arrow).toHaveAttribute('data-side', 'right') + expect(arrow).toHaveAttribute('data-uncentered') + expect(arrow).toHaveTextContent('right uncentered') + expect(contexts.positionerArrowRef.value).toBe(arrow) + }) + + it('throws when rendered without a TooltipPositioner ancestor', () => { + const contexts = createTooltipContexts() + + const TestComponent = defineComponent({ + components: { TooltipArrow }, + setup() { + provide(tooltipRootContextKey, contexts.root) + }, + template: ``, + }) + + expect(() => render(TestComponent)).toThrow( + 'Base UI Vue: TooltipPositionerContext is missing. must be placed within .', + ) + }) + + it('does not expose tracking-cursor as arrow instant state', () => { + render(defineComponent({ + components: { + TooltipArrow, + TooltipPortal, + TooltipPositioner, + TooltipRoot, + }, + template: ` + + + + + + + + `, + })) + + expect(screen.getByTestId('positioner')).toHaveAttribute( + 'data-instant', + 'tracking-cursor', + ) + expect(screen.getByTestId('arrow')).not.toHaveAttribute('data-instant') + }) +}) diff --git a/packages/core/src/tooltip/arrow/TooltipArrow.vue b/packages/core/src/tooltip/arrow/TooltipArrow.vue index c1f909f..bfd480b 100644 --- a/packages/core/src/tooltip/arrow/TooltipArrow.vue +++ b/packages/core/src/tooltip/arrow/TooltipArrow.vue @@ -1,10 +1,19 @@ + +