fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PopperArrowProps } from '../popper';
export interface TooltipArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../popper';
const { width = 10, height = 5 } = defineProps<TooltipArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { TooltipContentImplEmits, TooltipContentImplProps } from './TooltipContentImpl.vue';
export interface TooltipContentProps extends TooltipContentImplProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export type TooltipContentEmits = TooltipContentImplEmits;
</script>
<script setup lang="ts">
import { Presence } from '../presence';
import TooltipContentImpl from './TooltipContentImpl.vue';
import { useTooltipContext } from './context';
const { forceMount = false, ...contentProps } = defineProps<TooltipContentProps>();
const emit = defineEmits<TooltipContentEmits>();
const ctx = useTooltipContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<TooltipContentImpl
v-bind="contentProps"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
>
<slot />
</TooltipContentImpl>
</Presence>
</template>
@@ -0,0 +1,149 @@
<script lang="ts">
import type { PopperContentProps } from '../popper';
import type { PrimitiveProps } from '../primitive';
export interface TooltipContentImplProps extends PrimitiveProps, Pick<
PopperContentProps,
| 'side'
| 'sideOffset'
| 'sideFlip'
| 'align'
| 'alignOffset'
| 'alignFlip'
| 'avoidCollisions'
| 'collisionBoundary'
| 'collisionPadding'
| 'arrowPadding'
| 'sticky'
| 'hideWhenDetached'
| 'positionStrategy'
| 'updatePositionStrategy'
> {
/**
* Accessible label for screen readers when the visible content is not descriptive
* enough (e.g. icon-only). Falls back to the rendered `textContent`.
*/
ariaLabel?: string;
}
export interface TooltipContentImplEmits {
/** Escape pressed while this tooltip is topmost. Preventable. */
escapeKeyDown: [event: KeyboardEvent];
/** Pointer down outside the tooltip. Preventable. */
pointerDownOutside: [event: PointerEvent | MouseEvent];
}
</script>
<script setup lang="ts">
import { computed, onMounted, onScopeDispose } from 'vue';
import { DismissableLayer } from '../dismissable-layer';
import { PopperContent } from '../popper';
import { TOOLTIP_OPEN_EVENT } from './utils';
import { VisuallyHidden } from '../visually-hidden';
import { useForwardExpose } from '@robonen/vue';
import { useTooltipContext } from './context';
const {
side = 'top',
sideOffset = 0,
sideFlip = true,
align = 'center',
alignOffset = 0,
alignFlip = true,
avoidCollisions = true,
collisionBoundary = [],
collisionPadding = 0,
arrowPadding = 0,
sticky = 'partial',
hideWhenDetached = false,
positionStrategy,
updatePositionStrategy,
ariaLabel,
as = 'div',
} = defineProps<TooltipContentImplProps>();
const emit = defineEmits<TooltipContentImplEmits>();
const ctx = useTooltipContext();
const { forwardRef, currentElement } = useForwardExpose();
const computedAriaLabel = computed(() => ariaLabel ?? currentElement.value?.textContent ?? '');
function onDocumentTooltipOpen() {
ctx.onClose();
}
function onScrollCapture(event: Event) {
const target = event.target as Node | null;
if (target && ctx.trigger.value && target.contains(ctx.trigger.value)) ctx.onClose();
}
onMounted(() => {
if (typeof globalThis === 'undefined') return;
globalThis.addEventListener('scroll', onScrollCapture, { capture: true });
document.addEventListener(TOOLTIP_OPEN_EVENT, onDocumentTooltipOpen);
});
onScopeDispose(() => {
if (typeof globalThis === 'undefined') return;
globalThis.removeEventListener('scroll', onScrollCapture, { capture: true });
document.removeEventListener(TOOLTIP_OPEN_EVENT, onDocumentTooltipOpen);
});
function onPointerDownOutside(event: PointerEvent | MouseEvent) {
if (
ctx.disableClosingTrigger.value
&& ctx.trigger.value
&& ctx.trigger.value.contains(event.target as Node)
) {
event.preventDefault();
}
emit('pointerDownOutside', event);
}
</script>
<template>
<DismissableLayer
as="template"
:disable-outside-pointer-events="false"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="onPointerDownOutside"
@focus-outside.prevent
@dismiss="ctx.onClose"
>
<PopperContent
:ref="forwardRef"
:as="as"
:side="side"
:side-offset="sideOffset"
:side-flip="sideFlip"
:align="align"
:align-offset="alignOffset"
:align-flip="alignFlip"
:avoid-collisions="avoidCollisions"
:collision-boundary="collisionBoundary"
:collision-padding="collisionPadding"
:arrow-padding="arrowPadding"
:sticky="sticky"
:hide-when-detached="hideWhenDetached"
:position-strategy="positionStrategy"
:update-position-strategy="updatePositionStrategy"
:data-state="ctx.stateAttribute.value"
:style="{
'--tooltip-content-transform-origin': 'var(--popper-transform-origin)',
'--tooltip-content-available-width': 'var(--popper-available-width)',
'--tooltip-content-available-height': 'var(--popper-available-height)',
'--tooltip-trigger-width': 'var(--popper-anchor-width)',
'--tooltip-trigger-height': 'var(--popper-anchor-height)',
}"
>
<slot />
<VisuallyHidden
:id="ctx.contentId.value"
role="tooltip"
>
{{ computedAriaLabel }}
</VisuallyHidden>
</PopperContent>
</DismissableLayer>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../teleport';
export interface TooltipPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import { Portal } from '../teleport';
const props = defineProps<TooltipPortalProps>();
</script>
<template>
<Portal v-bind="props">
<slot />
</Portal>
</template>
@@ -0,0 +1,89 @@
<script lang="ts">
export interface TooltipProviderProps {
/**
* Hover delay before opening, in ms.
* @default 700
*/
delayDuration?: number;
/**
* After a tooltip closes, subsequent tooltips open without delay for this many ms.
* @default 300
*/
skipDelayDuration?: number;
/**
* When `true`, the tooltip closes as soon as the pointer leaves the trigger
* (hoverable content disabled). Has a11y consequences.
* @default false
*/
disableHoverableContent?: boolean;
/**
* When `true`, clicking the trigger does not close the tooltip.
* @default false
*/
disableClosingTrigger?: boolean;
/**
* Disable all tooltips inside this provider.
* @default false
*/
disabled?: boolean;
/**
* Skip opening on focus that did not come from the keyboard
* (matched via `:focus-visible`).
* @default false
*/
ignoreNonKeyboardFocus?: boolean;
}
</script>
<script setup lang="ts">
import { onScopeDispose, ref, toRef } from 'vue';
import { provideTooltipProviderContext } from './context';
defineOptions({ inheritAttrs: false });
const {
delayDuration = 700,
skipDelayDuration = 300,
disableHoverableContent = false,
disableClosingTrigger = false,
disabled = false,
ignoreNonKeyboardFocus = false,
} = defineProps<TooltipProviderProps>();
const isOpenDelayed = ref(true);
const isPointerInTransitRef = ref(false);
let skipTimer: ReturnType<typeof setTimeout> | undefined;
function clearSkipTimer() {
if (skipTimer !== undefined) {
clearTimeout(skipTimer);
skipTimer = undefined;
}
}
onScopeDispose(clearSkipTimer);
provideTooltipProviderContext({
isOpenDelayed,
delayDuration: toRef(() => delayDuration),
skipDelayDuration: toRef(() => skipDelayDuration),
disableHoverableContent: toRef(() => disableHoverableContent),
disableClosingTrigger: toRef(() => disableClosingTrigger),
disabled: toRef(() => disabled),
ignoreNonKeyboardFocus: toRef(() => ignoreNonKeyboardFocus),
isPointerInTransitRef,
onOpen() {
clearSkipTimer();
isOpenDelayed.value = false;
},
onClose() {
clearSkipTimer();
skipTimer = setTimeout(() => {
isOpenDelayed.value = true;
}, skipDelayDuration);
},
});
</script>
<template>
<slot />
</template>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
export interface TooltipRootProps {
/** Initial open state in uncontrolled mode. */
defaultOpen?: boolean;
/**
* Per-tooltip override for the provider's `delayDuration`.
* @default 700
*/
delayDuration?: number;
/**
* Per-tooltip override for the provider's `disableHoverableContent`.
*/
disableHoverableContent?: boolean;
/**
* Per-tooltip override for the provider's `disableClosingTrigger`.
*/
disableClosingTrigger?: boolean;
/**
* Per-tooltip override for the provider's `disabled`.
*/
disabled?: boolean;
/**
* Per-tooltip override for the provider's `ignoreNonKeyboardFocus`.
*/
ignoreNonKeyboardFocus?: boolean;
}
</script>
<script setup lang="ts">
import { computed, onScopeDispose, ref } from 'vue';
import { provideTooltipContext, useTooltipProviderContext } from './context';
import { PopperRoot } from '../popper';
import { TOOLTIP_OPEN_EVENT } from './utils';
import { useId } from '../config-provider';
defineOptions({ inheritAttrs: false });
const {
defaultOpen = false,
delayDuration: delayDurationProp,
disableHoverableContent: disableHoverableContentProp,
disableClosingTrigger: disableClosingTriggerProp,
disabled: disabledProp,
ignoreNonKeyboardFocus: ignoreNonKeyboardFocusProp,
} = defineProps<TooltipRootProps>();
defineSlots<{
default?: (props: { open: boolean }) => unknown;
}>();
const providerCtx = useTooltipProviderContext();
const local = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: external => external ?? local.value,
set: (value) => {
local.value = value;
return value;
},
});
const delayDuration = computed(() => delayDurationProp ?? providerCtx.delayDuration.value);
const disableHoverableContent = computed(
() => disableHoverableContentProp ?? providerCtx.disableHoverableContent.value,
);
const disableClosingTrigger = computed(
() => disableClosingTriggerProp ?? providerCtx.disableClosingTrigger.value,
);
const disabled = computed(() => disabledProp ?? providerCtx.disabled.value);
const ignoreNonKeyboardFocus = computed(
() => ignoreNonKeyboardFocusProp ?? providerCtx.ignoreNonKeyboardFocus.value,
);
const wasOpenDelayed = ref(false);
const trigger = ref<HTMLElement>();
const contentId = useId(undefined, 'tooltip-content');
const stateAttribute = computed<'closed' | 'delayed-open' | 'instant-open'>(() => {
if (!open.value) return 'closed';
return wasOpenDelayed.value ? 'delayed-open' : 'instant-open';
});
let openTimer: ReturnType<typeof setTimeout> | undefined;
function clearOpenTimer() {
if (openTimer !== undefined) {
clearTimeout(openTimer);
openTimer = undefined;
}
}
onScopeDispose(clearOpenTimer);
function dispatchOpenEvent() {
if (typeof document === 'undefined') return;
document.dispatchEvent(new CustomEvent(TOOLTIP_OPEN_EVENT));
}
function handleOpen() {
clearOpenTimer();
wasOpenDelayed.value = false;
if (!open.value) {
open.value = true;
providerCtx.onOpen();
dispatchOpenEvent();
}
}
function handleClose() {
clearOpenTimer();
if (open.value) {
open.value = false;
providerCtx.onClose();
}
}
function handleDelayedOpen() {
clearOpenTimer();
openTimer = setTimeout(() => {
wasOpenDelayed.value = true;
if (!open.value) {
open.value = true;
providerCtx.onOpen();
dispatchOpenEvent();
}
}, delayDuration.value);
}
provideTooltipContext({
contentId,
open,
stateAttribute,
trigger,
disableHoverableContent,
disableClosingTrigger,
disabled,
ignoreNonKeyboardFocus,
onTriggerChange(el) {
trigger.value = el;
},
onTriggerEnter() {
if (providerCtx.isOpenDelayed.value) handleDelayedOpen();
else handleOpen();
},
onTriggerLeave() {
if (disableHoverableContent.value) handleClose();
else clearOpenTimer();
},
onOpen: handleOpen,
onClose: handleClose,
});
</script>
<template>
<PopperRoot>
<slot :open="open" />
</PopperRoot>
</template>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface TooltipTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useTooltipContext, useTooltipProviderContext } from './context';
import { PopperAnchor } from '../popper';
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
const { as = 'button' } = defineProps<TooltipTriggerProps>();
const ctx = useTooltipContext();
const providerCtx = useTooltipProviderContext();
const { forwardRef, currentElement } = useForwardExpose();
const isPointerDown = ref(false);
const hasPointerMoveOpened = ref(false);
onMounted(() => {
ctx.onTriggerChange(currentElement.value);
});
function onPointerMove(event: PointerEvent) {
if (ctx.disabled.value) return;
if (event.pointerType === 'touch') return;
if (hasPointerMoveOpened.value || providerCtx.isPointerInTransitRef.value) return;
ctx.onTriggerEnter();
hasPointerMoveOpened.value = true;
}
function onPointerLeave() {
if (ctx.disabled.value) return;
ctx.onTriggerLeave();
hasPointerMoveOpened.value = false;
}
function onPointerUp() {
// Defer reset by one tick so `focus` handlers can see the latest `isPointerDown`.
setTimeout(() => {
isPointerDown.value = false;
}, 1);
}
function onPointerDown() {
if (ctx.disabled.value) return;
if (ctx.open.value && !ctx.disableClosingTrigger.value) ctx.onClose();
isPointerDown.value = true;
document.addEventListener('pointerup', onPointerUp, { once: true });
}
function onFocus(event: FocusEvent) {
if (ctx.disabled.value) return;
if (isPointerDown.value) return;
if (
ctx.ignoreNonKeyboardFocus.value
&& !(event.target as HTMLElement | null)?.matches?.(':focus-visible')
) return;
ctx.onOpen();
}
function onBlur() {
if (ctx.disabled.value) return;
ctx.onClose();
}
function onClick() {
if (ctx.disabled.value) return;
if (!ctx.disableClosingTrigger.value) ctx.onClose();
}
</script>
<template>
<PopperAnchor as="template">
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
:aria-describedby="ctx.open.value ? ctx.contentId.value : undefined"
:data-state="ctx.stateAttribute.value"
data-tooltip-trigger
@pointermove="onPointerMove"
@pointerleave="onPointerLeave"
@pointerdown="onPointerDown"
@focus="onFocus"
@blur="onBlur"
@click="onClick"
>
<slot />
</Primitive>
</PopperAnchor>
</template>
@@ -0,0 +1,291 @@
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '../../index';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
vi.useRealTimers();
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function mountTooltip(options: {
open?: boolean;
defaultOpen?: boolean;
delayDuration?: number;
skipDelayDuration?: number;
disabled?: boolean;
disableHoverableContent?: boolean;
disableClosingTrigger?: boolean;
ignoreNonKeyboardFocus?: boolean;
onUpdateOpen?: (v: boolean) => void;
forceMount?: boolean;
} = {}) {
const Wrapper = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{
delayDuration: options.delayDuration,
skipDelayDuration: options.skipDelayDuration,
},
{
default: () =>
h(
TooltipRoot,
{
open: options.open,
defaultOpen: options.defaultOpen,
disabled: options.disabled,
disableHoverableContent: options.disableHoverableContent,
disableClosingTrigger: options.disableClosingTrigger,
ignoreNonKeyboardFocus: options.ignoreNonKeyboardFocus,
'onUpdate:open': options.onUpdateOpen,
},
{
default: () => [
h(TooltipTrigger, null, { default: () => 'Trigger' }),
h(
TooltipContent,
{ forceMount: options.forceMount },
{ default: () => 'Tooltip body' },
),
],
},
),
},
);
},
});
return track(mount(Wrapper, { attachTo: document.body }));
}
function getTrigger(): HTMLButtonElement {
return document.querySelector('[data-tooltip-trigger]') as HTMLButtonElement;
}
function getTooltip(): HTMLElement | null {
return document.querySelector('[role="tooltip"]');
}
describe('Tooltip', () => {
it('renders trigger with closed state by default', () => {
mountTooltip();
const trigger = getTrigger();
expect(trigger).toBeTruthy();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(trigger.getAttribute('aria-describedby')).toBe(null);
expect(getTooltip()).toBeNull();
});
it('opens with defaultOpen and exposes aria-describedby', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
const trigger = getTrigger();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
expect(trigger.getAttribute('aria-describedby')).toBeTruthy();
const tip = getTooltip();
expect(tip).toBeTruthy();
expect(tip!.id).toBe(trigger.getAttribute('aria-describedby'));
});
it('opens on focus and closes on blur', async () => {
mountTooltip();
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
trigger.dispatchEvent(new FocusEvent('blur'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('respects controlled v-model', async () => {
const onUpdate = vi.fn();
const Wrapper = defineComponent({
props: { open: { type: Boolean, default: false } },
emits: ['update:open'],
setup(props, { emit }) {
return () =>
h(
TooltipProvider,
null,
{
default: () =>
h(
TooltipRoot,
{
open: props.open,
'onUpdate:open': (v: boolean) => {
onUpdate(v);
emit('update:open', v);
},
},
{
default: () => [
h(TooltipTrigger, null, { default: () => 'T' }),
h(TooltipContent, null, { default: () => 'body' }),
],
},
),
},
);
},
});
const wrapper = track(mount(Wrapper, { attachTo: document.body, props: { open: false } }));
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(onUpdate).toHaveBeenCalledWith(true);
expect(trigger.getAttribute('data-state')).toBe('closed');
await wrapper.setProps({ open: true });
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
});
it('does not open when disabled', async () => {
mountTooltip({ disabled: true });
const trigger = getTrigger();
trigger.dispatchEvent(new FocusEvent('focus'));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
expect(getTooltip()).toBeNull();
});
it('uses delayed-open after delay window via pointer', async () => {
vi.useFakeTimers();
mountTooltip({ delayDuration: 100, skipDelayDuration: 50 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
// Not opened yet.
expect(trigger.getAttribute('data-state')).toBe('closed');
vi.advanceTimersByTime(100);
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('delayed-open');
});
it('skips delay for second tooltip within skipDelayDuration window', async () => {
vi.useFakeTimers();
const Wrapper = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 500, skipDelayDuration: 300 },
{
default: () => [
h(
TooltipRoot,
null,
{
default: () => [
h(TooltipTrigger, { 'data-id': 'a' }, { default: () => 'A' }),
h(TooltipContent, null, { default: () => 'A body' }),
],
},
),
h(
TooltipRoot,
null,
{
default: () => [
h(TooltipTrigger, { 'data-id': 'b' }, { default: () => 'B' }),
h(TooltipContent, null, { default: () => 'B body' }),
],
},
),
],
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
const a = document.querySelector('[data-id="a"]') as HTMLElement;
const b = document.querySelector('[data-id="b"]') as HTMLElement;
// Open A with delay.
a.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
vi.advanceTimersByTime(500);
await nextTick();
expect(a.getAttribute('data-state')).toBe('delayed-open');
// Close A via blur-equivalent: pointerleave + disableHoverable would do it,
// but here we just close via focus loss simulation through trigger event.
a.dispatchEvent(new FocusEvent('blur'));
await nextTick();
expect(a.getAttribute('data-state')).toBe('closed');
// Within the skip window — moving over B should open it instantly.
vi.advanceTimersByTime(100);
b.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse' }));
await nextTick();
expect(b.getAttribute('data-state')).toBe('instant-open');
});
it('does not open on touch pointers (handled by long-press elsewhere)', async () => {
mountTooltip({ delayDuration: 0 });
const trigger = getTrigger();
trigger.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'touch' }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('closes on Escape via dismissable layer', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('instant-open');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await nextTick();
expect(getTrigger().getAttribute('data-state')).toBe('closed');
});
it('closes when clicked unless disableClosingTrigger', async () => {
mountTooltip({ defaultOpen: true });
await nextTick();
const trigger = getTrigger();
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('closed');
});
it('keeps tooltip open on click when disableClosingTrigger is set', async () => {
mountTooltip({ defaultOpen: true, disableClosingTrigger: true });
await nextTick();
const trigger = getTrigger();
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(trigger.getAttribute('data-state')).toBe('instant-open');
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { ComputedRef, Ref } from 'vue';
import type { TooltipState } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface TooltipProviderContext {
/** Whether the next tooltip open should wait for the full `delayDuration`. */
isOpenDelayed: Ref<boolean>;
/** Hover delay before opening when `isOpenDelayed` is `true`. */
delayDuration: Ref<number>;
/** Skip-delay window after a tooltip closes — used for "menu-like" hover groups. */
skipDelayDuration: Ref<number>;
/** Set to `true` while the pointer is in the safe-area between trigger and content. */
isPointerInTransitRef: Ref<boolean>;
/** Globally disable hoverable content (pointer leaving trigger immediately closes). */
disableHoverableContent: Ref<boolean>;
/** Globally disable closing the tooltip by clicking the trigger. */
disableClosingTrigger: Ref<boolean>;
/** Globally disable all tooltips inside this provider. */
disabled: Ref<boolean>;
/** Skip opening on non-keyboard focus (avoids tooltips after closing dialogs / switching tabs). */
ignoreNonKeyboardFocus: Ref<boolean>;
onOpen: () => void;
onClose: () => void;
}
export const {
inject: useTooltipProviderContext,
provide: provideTooltipProviderContext,
} = useContextFactory<TooltipProviderContext>('TooltipProviderContext');
export interface TooltipContext {
contentId: ComputedRef<string>;
open: Ref<boolean>;
stateAttribute: ComputedRef<TooltipState>;
trigger: Ref<HTMLElement | undefined>;
disableHoverableContent: ComputedRef<boolean>;
disableClosingTrigger: ComputedRef<boolean>;
disabled: ComputedRef<boolean>;
ignoreNonKeyboardFocus: ComputedRef<boolean>;
onTriggerChange: (el: HTMLElement | undefined) => void;
onTriggerEnter: () => void;
onTriggerLeave: () => void;
onOpen: () => void;
onClose: () => void;
}
export const {
inject: useTooltipContext,
provide: provideTooltipContext,
} = useContextFactory<TooltipContext>('TooltipContext');
+26
View File
@@ -0,0 +1,26 @@
export { default as TooltipProvider } from './TooltipProvider.vue';
export { default as TooltipRoot } from './TooltipRoot.vue';
export { default as TooltipTrigger } from './TooltipTrigger.vue';
export { default as TooltipPortal } from './TooltipPortal.vue';
export { default as TooltipContent } from './TooltipContent.vue';
export { default as TooltipArrow } from './TooltipArrow.vue';
export {
useTooltipContext,
useTooltipProviderContext,
type TooltipContext,
type TooltipProviderContext,
} from './context';
export { TOOLTIP_OPEN_EVENT, type TooltipState } from './utils';
export type { TooltipProviderProps } from './TooltipProvider.vue';
export type { TooltipRootProps } from './TooltipRoot.vue';
export type { TooltipTriggerProps } from './TooltipTrigger.vue';
export type { TooltipPortalProps } from './TooltipPortal.vue';
export type { TooltipContentProps, TooltipContentEmits } from './TooltipContent.vue';
export type {
TooltipContentImplProps,
TooltipContentImplEmits,
} from './TooltipContentImpl.vue';
export type { TooltipArrowProps } from './TooltipArrow.vue';
+8
View File
@@ -0,0 +1,8 @@
/**
* Custom DOM event dispatched on `document` whenever any tooltip opens.
* Other open tooltips listen for it and close themselves so that only one
* tooltip is visible at a time without coupling them via a global registry.
*/
export const TOOLTIP_OPEN_EVENT = 'tooltip.open';
export type TooltipState = 'closed' | 'delayed-open' | 'instant-open';