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:
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user