From bdb703e3675ec3d73ff44f60e73bd8d3bf308b24 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sat, 9 May 2026 20:53:14 -0500 Subject: [PATCH 01/17] chore(createSortable): add missing doc block --- packages/0/src/composables/createSortable/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/0/src/composables/createSortable/index.ts b/packages/0/src/composables/createSortable/index.ts index 8f2541482..dbb4c6e7a 100644 --- a/packages/0/src/composables/createSortable/index.ts +++ b/packages/0/src/composables/createSortable/index.ts @@ -146,6 +146,20 @@ export interface SortableContext< * ``` */ on: SortableEventListener + /** + * Unsubscribe from a `move:ticket` listener. Must be called with the same callback reference used to subscribe. + * + * @example + * ```ts + * function onMove ({ ticket, from, to }: SortableMovePayload) { + * console.log(ticket.id, from, to) + * } + * + * sortable.on('move:ticket', onMove) + * // later... + * sortable.off('move:ticket', onMove) + * ``` + */ off: SortableEventListener /** * Move a ticket to a target index. Other tickets shift to fill. From e967e8b576acd23bef7899ad69afe3dd90ea0650 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:18:23 -0500 Subject: [PATCH 02/17] refactor(usePopover): rename show/hide delays and compose useDelay Renames `showDelay` -> `openDelay` and `hideDelay` -> `closeDelay` to match the vocabulary used by useDelay, useTimer, the broader v0 naming convention, and every other tooltip / popover library. Replaces the bespoke `useTimer`-per-direction plumbing with a single `useDelay` instance, removing duplicated state and giving the composable promise-based resolution and pause/resume capability for free. Adds a `cancel()` method that forwards to `delay.stop()` for consumers that need to abort a pending transition (used by the upcoming Tooltip component). usePopover is `preview`, so the rename is allowed. --- .../pages/composables/system/use-popover.md | 5 +- .../src/composables/usePopover/index.test.ts | 30 ++++++--- .../0/src/composables/usePopover/index.ts | 67 +++++++------------ 3 files changed, 50 insertions(+), 52 deletions(-) diff --git a/apps/docs/src/pages/composables/system/use-popover.md b/apps/docs/src/pages/composables/system/use-popover.md index 47554292b..bd076834d 100644 --- a/apps/docs/src/pages/composables/system/use-popover.md +++ b/apps/docs/src/pages/composables/system/use-popover.md @@ -80,8 +80,8 @@ flowchart TD | `positionArea` | `string` | `'bottom'` | CSS `position-area` value — controls where the content appears relative to the anchor | | `positionTry` | `string` | `'most-width bottom'` | CSS `position-try-fallbacks` value — fallback positions when the primary area overflows | | `isOpen` | `Ref` | — | External ref for bidirectional open state (e.g., from `defineModel`) | -| `showDelay` | `number` | `0` | Milliseconds to wait before showing the popover (hover/focus use cases) | -| `hideDelay` | `number` | `0` | Milliseconds to wait before hiding the popover (prevents premature close on mouse leave) | +| `openDelay` | `MaybeRefOrGetter` | `0` | Milliseconds to wait before opening the popover | +| `closeDelay` | `MaybeRefOrGetter` | `0` | Milliseconds to wait before closing the popover | ## Reactivity @@ -91,6 +91,7 @@ flowchart TD | `open()` | - | Open the popover | | `close()` | - | Close the popover | | `toggle()` | - | Toggle open/close | +| `cancel()` | - | Cancel any pending open or close transition | | `attach(el)` | - | Wire native show/hide watch + toggle event sync to a content element | | `anchorStyles` | | Readonly Ref, CSS `anchor-name` for the activator element | | `contentAttrs` | | Readonly Ref, `id` and `popover` attribute for the content element | diff --git a/packages/0/src/composables/usePopover/index.test.ts b/packages/0/src/composables/usePopover/index.test.ts index ff32699cf..d3c146a61 100644 --- a/packages/0/src/composables/usePopover/index.test.ts +++ b/packages/0/src/composables/usePopover/index.test.ts @@ -70,8 +70,8 @@ describe('usePopover', () => { }) describe('delay', () => { - it('should delay opening with showDelay', () => { - const popover = usePopover({ showDelay: 200 }) + it('should delay opening with openDelay', () => { + const popover = usePopover({ openDelay: 200 }) popover.open() expect(popover.isOpen.value).toBe(false) @@ -83,9 +83,9 @@ describe('usePopover', () => { expect(popover.isOpen.value).toBe(true) }) - it('should delay closing with hideDelay', () => { + it('should delay closing with closeDelay', () => { const isOpen = shallowRef(true) - const popover = usePopover({ isOpen, hideDelay: 300 }) + const popover = usePopover({ isOpen, closeDelay: 300 }) popover.close() expect(popover.isOpen.value).toBe(true) @@ -97,8 +97,8 @@ describe('usePopover', () => { expect(popover.isOpen.value).toBe(false) }) - it('should cancel pending show when closing', () => { - const popover = usePopover({ showDelay: 200 }) + it('should cancel pending open when closing', () => { + const popover = usePopover({ openDelay: 200 }) popover.open() vi.advanceTimersByTime(100) @@ -109,9 +109,9 @@ describe('usePopover', () => { expect(popover.isOpen.value).toBe(false) }) - it('should cancel pending hide when opening', () => { + it('should cancel pending close when opening', () => { const isOpen = shallowRef(true) - const popover = usePopover({ isOpen, hideDelay: 300 }) + const popover = usePopover({ isOpen, closeDelay: 300 }) popover.close() vi.advanceTimersByTime(100) @@ -123,6 +123,18 @@ describe('usePopover', () => { }) }) + describe('cancel', () => { + it('should cancel a pending open transition', () => { + vi.useFakeTimers() + const popover = usePopover({ openDelay: 500 }) + popover.open() + popover.cancel() + vi.advanceTimersByTime(500) + expect(popover.isOpen.value).toBe(false) + vi.useRealTimers() + }) + }) + describe('anchorStyles', () => { it('should generate anchor-name from id', () => { const popover = usePopover({ id: 'test' }) @@ -177,7 +189,7 @@ describe('usePopover', () => { let popover: ReturnType scope.run(() => { - popover = usePopover({ showDelay: 200 }) + popover = usePopover({ openDelay: 200 }) popover.open() }) diff --git a/packages/0/src/composables/usePopover/index.ts b/packages/0/src/composables/usePopover/index.ts index 2f09ed4e0..6ee754996 100644 --- a/packages/0/src/composables/usePopover/index.ts +++ b/packages/0/src/composables/usePopover/index.ts @@ -5,32 +5,31 @@ * * @remarks * Composable for native popover API behavior with CSS anchor positioning. - * Manages open/close state, anchor styles, content attributes, and - * bidirectional sync between reactive state and native popover events. + * Manages open/close state, anchor styles, content attributes, bidirectional + * sync between reactive state and native popover events, and configurable + * open / close delays via `useDelay`. * * Key features: * - Native popover API (showPopover/hidePopover) * - CSS anchor positioning (position-area, position-try-fallbacks) + * - Reactive open/close delays via `useDelay` * - Toggle event sync for native state changes * - SSR-safe (no DOM ops outside browser) * - Optional external isOpen ref for v-model integration * - * Perfect for building select, combobox, tooltip, and menu components - * without wrapping the Popover compound component. - * * @example * ```ts * import { usePopover } from '@vuetify/v0' * - * const popover = usePopover() + * const popover = usePopover({ openDelay: 200, closeDelay: 100 }) * popover.open() * popover.toggle() * ``` */ // Composables +import { useDelay } from '#v0/composables/useDelay' import { useEventListener } from '#v0/composables/useEventListener' -import { useTimer } from '#v0/composables/useTimer' // Utilities import { useId } from '#v0/utilities' @@ -48,10 +47,10 @@ export interface PopoverOptions { positionTry?: string /** External ref for bidirectional open state (e.g., from defineModel) */ isOpen?: Ref - /** Delay in ms before showing the popover. @default 0 */ - showDelay?: number - /** Delay in ms before hiding the popover. @default 0 */ - hideDelay?: number + /** Delay in ms before opening the popover. @default 0 */ + openDelay?: MaybeRefOrGetter + /** Delay in ms before closing the popover. @default 0 */ + closeDelay?: MaybeRefOrGetter } export interface PopoverReturn { @@ -59,12 +58,14 @@ export interface PopoverReturn { isOpen: Ref /** Unique ID for the popover */ id: string - /** Open the popover */ + /** Open the popover (respects openDelay) */ open: () => void - /** Close the popover */ + /** Close the popover (respects closeDelay) */ close: () => void /** Toggle open/close */ toggle: () => void + /** Cancel any pending open or close transition */ + cancel: () => void /** Styles to spread on the activator element (anchor-name) */ anchorStyles: Readonly>> /** Attrs to spread on the content element (id, popover) */ @@ -80,42 +81,23 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { id: _id, positionArea = 'bottom', positionTry = 'most-width bottom', - showDelay = 0, - hideDelay = 0, + openDelay, + closeDelay, } = options const id = _id ?? useId() const isOpen = options.isOpen ?? shallowRef(false) - const showTimer = showDelay > 0 - ? useTimer(() => { - isOpen.value = true - }, { duration: showDelay }) - : undefined - const hideTimer = hideDelay > 0 - ? useTimer(() => { - isOpen.value = false - }, { duration: hideDelay }) - : undefined + const delay = useDelay(direction => { + isOpen.value = direction + }, { openDelay, closeDelay }) function open () { - hideTimer?.stop() - - if (showTimer) { - showTimer.start() - } else { - isOpen.value = true - } + delay.start(true) } function close () { - showTimer?.stop() - - if (hideTimer) { - hideTimer.start() - } else { - isOpen.value = false - } + delay.start(false) } function toggle () { @@ -126,6 +108,10 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { } } + function cancel () { + delay.stop() + } + const anchorStyles = toRef(() => ({ anchorName: `--${id}`, })) @@ -155,7 +141,6 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { watch(isOpen, value => { const element = toValue(el) - // Guard against operations on disconnected elements (e.g., during unmount) if (!element?.isConnected) return if (value === element.matches?.(':popover-open')) return @@ -171,7 +156,6 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { 'toggle', (e: ToggleEvent) => { const element = toValue(el) - // Guard against events firing during unmount if (!element?.isConnected) return isOpen.value = e.newState === 'open' }, @@ -185,6 +169,7 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { open, close, toggle, + cancel, anchorStyles, contentAttrs, contentStyles, From cf5c781f8563059753e41d3324b77289ee434210 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:21:42 -0500 Subject: [PATCH 03/17] test(usePopover): tighten cancel coverage - drop redundant inline vi.useFakeTimers/useRealTimers (suite-level setup already handles them) - wrap usePopover instances in effectScope for onScopeDispose discipline (matches the auto-cleanup test pattern) - add a close-direction cancel case for symmetry with the existing open-direction case --- .../src/composables/usePopover/index.test.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/0/src/composables/usePopover/index.test.ts b/packages/0/src/composables/usePopover/index.test.ts index d3c146a61..699057ea7 100644 --- a/packages/0/src/composables/usePopover/index.test.ts +++ b/packages/0/src/composables/usePopover/index.test.ts @@ -125,13 +125,28 @@ describe('usePopover', () => { describe('cancel', () => { it('should cancel a pending open transition', () => { - vi.useFakeTimers() - const popover = usePopover({ openDelay: 500 }) - popover.open() - popover.cancel() - vi.advanceTimersByTime(500) - expect(popover.isOpen.value).toBe(false) - vi.useRealTimers() + const scope = effectScope() + scope.run(() => { + const popover = usePopover({ openDelay: 500 }) + popover.open() + popover.cancel() + vi.advanceTimersByTime(500) + expect(popover.isOpen.value).toBe(false) + }) + scope.stop() + }) + + it('should cancel a pending close transition', () => { + const scope = effectScope() + scope.run(() => { + const popover = usePopover({ closeDelay: 500 }) + popover.isOpen.value = true + popover.close() + popover.cancel() + vi.advanceTimersByTime(500) + expect(popover.isOpen.value).toBe(true) + }) + scope.stop() }) }) From 1bbad28987d855095be633a5b6305a63612fff9e Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:24:48 -0500 Subject: [PATCH 04/17] feat(useTooltip): plugin trinity for region-scoped delay coordination Adds the createTooltipContext / createTooltipPlugin / useTooltip trinity. Holds reactive `openDelay` / `closeDelay` / `skipDelay` defaults plus an internal createRegistry of currently-open tooltip tickets so per-instance Tooltip components can skip their open delay when another tooltip is already visible (matches the warmup window in React Aria and Radix). Defaults follow Radix: 700ms open, 150ms close, 300ms skip. Used by the upcoming Tooltip component family. --- packages/0/src/composables/index.ts | 1 + .../src/composables/useTooltip/index.test.ts | 141 ++++++++++++++++++ .../0/src/composables/useTooltip/index.ts | 132 ++++++++++++++++ packages/0/src/maturity.json | 5 + 4 files changed, 279 insertions(+) create mode 100644 packages/0/src/composables/useTooltip/index.test.ts create mode 100644 packages/0/src/composables/useTooltip/index.ts diff --git a/packages/0/src/composables/index.ts b/packages/0/src/composables/index.ts index 523335839..56a19597d 100644 --- a/packages/0/src/composables/index.ts +++ b/packages/0/src/composables/index.ts @@ -63,4 +63,5 @@ export * from './useStorage' export * from './useTheme' export * from './useTimer' export * from './useToggleScope' +export * from './useTooltip' export * from './useVirtualFocus' diff --git a/packages/0/src/composables/useTooltip/index.test.ts b/packages/0/src/composables/useTooltip/index.test.ts new file mode 100644 index 000000000..c2d601a45 --- /dev/null +++ b/packages/0/src/composables/useTooltip/index.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Utilities +import { effectScope } from 'vue' + +// Composables +import { createTooltipContext, createTooltipPlugin } from './index' + +describe('useTooltip', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('defaults', () => { + it('should expose default delays', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext() + expect(ctx.openDelay.value).toBe(700) + expect(ctx.closeDelay.value).toBe(150) + expect(ctx.skipDelay.value).toBe(300) + expect(ctx.disabled.value).toBe(false) + }) + scope.stop() + }) + + it('should override defaults from options', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext({ + openDelay: 500, + closeDelay: 200, + skipDelay: 400, + disabled: true, + }) + expect(ctx.openDelay.value).toBe(500) + expect(ctx.closeDelay.value).toBe(200) + expect(ctx.skipDelay.value).toBe(400) + expect(ctx.disabled.value).toBe(true) + }) + scope.stop() + }) + }) + + describe('registry', () => { + it('should track open tooltips via register / unregister', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext() + expect(ctx.isAnyOpen.value).toBe(false) + + const ticket = ctx.register({ id: 't:1' }) + expect(ctx.isAnyOpen.value).toBe(true) + + ctx.unregister(ticket.id) + expect(ctx.isAnyOpen.value).toBe(false) + }) + scope.stop() + }) + }) + + describe('skip-window', () => { + it('should skip open delay when another tooltip is open', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext() + const ticket = ctx.register({ id: 't:1' }) + + expect(ctx.shouldSkipOpenDelay()).toBe(true) + ctx.unregister(ticket.id) + }) + scope.stop() + }) + + it('should skip open delay within skipDelay window after last close', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext({ skipDelay: 300 }) + const ticket = ctx.register({ id: 't:1' }) + ctx.unregister(ticket.id) + + vi.advanceTimersByTime(200) + expect(ctx.shouldSkipOpenDelay()).toBe(true) + + vi.advanceTimersByTime(200) // 400ms total + expect(ctx.shouldSkipOpenDelay()).toBe(false) + }) + scope.stop() + }) + + it('should not skip when no tooltips have ever opened', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext({ skipDelay: 300 }) + expect(ctx.shouldSkipOpenDelay()).toBe(false) + }) + scope.stop() + }) + }) + + describe('plugin install', () => { + it('should expose useTooltip after app.use(createTooltipPlugin())', async () => { + const { createApp, defineComponent, h } = await import('vue') + const { useTooltip } = await import('./index') + + let captured: ReturnType | undefined + + const Probe = defineComponent({ + setup () { + captured = useTooltip() + return () => h('div') + }, + }) + + const app = createApp(Probe) + app.use(createTooltipPlugin({ openDelay: 400 })) + const root = document.createElement('div') + app.mount(root) + + expect(captured?.openDelay.value).toBe(400) + + app.unmount() + }) + }) + + describe('namespace', () => { + it('should support component-level provideTooltipContext for region scoping', () => { + // Verify the trinity exports a provider symbol for region overrides + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext({ openDelay: 100, namespace: 'test:tooltip' }) + expect(ctx.openDelay.value).toBe(100) + }) + scope.stop() + }) + }) +}) diff --git a/packages/0/src/composables/useTooltip/index.ts b/packages/0/src/composables/useTooltip/index.ts new file mode 100644 index 000000000..3f140f21b --- /dev/null +++ b/packages/0/src/composables/useTooltip/index.ts @@ -0,0 +1,132 @@ +/** + * @module useTooltip + * + * @see https://0.vuetifyjs.com/composables/plugins/use-tooltip + * + * @remarks + * Region-scoped tooltip coordination plugin. Holds shared delay defaults and + * a small registry of currently-open tooltips so individual tooltip instances + * can skip their open delay when another tooltip is already visible (the + * "warmup" pattern from React Aria / Radix). + * + * Use the plugin form (`app.use(createTooltipPlugin())`) for app-wide + * defaults, or the `` provider component for region-scoped + * overrides. + * + * @example + * ```ts + * import { createTooltipPlugin } from '@vuetify/v0' + * + * app.use(createTooltipPlugin({ openDelay: 500 })) + * ``` + */ + +// Composables +import { createPluginContext } from '#v0/composables/createPlugin' +import { createRegistry } from '#v0/composables/createRegistry' + +// Utilities +import { shallowRef, toRef, toValue } from 'vue' + +// Types +import type { RegistryTicket, RegistryTicketInput } from '#v0/composables/createRegistry' +import type { ID } from '#v0/types' +import type { MaybeRefOrGetter, Ref } from 'vue' + +export interface TooltipOptions { + /** Default open delay in ms. @default 700 */ + openDelay?: MaybeRefOrGetter + /** Default close delay in ms. @default 150 */ + closeDelay?: MaybeRefOrGetter + /** Window in ms after a tooltip closes during which the next open is instant. @default 300 */ + skipDelay?: MaybeRefOrGetter + /** Disable all tooltips in this region. @default false */ + disabled?: MaybeRefOrGetter +} + +export interface TooltipContext { + /** Reactive open delay in ms. */ + openDelay: Readonly> + /** Reactive close delay in ms. */ + closeDelay: Readonly> + /** Reactive skip-window length in ms. */ + skipDelay: Readonly> + /** Reactive disabled flag for the region. */ + disabled: Readonly> + /** True when at least one tooltip is currently open in this region. */ + isAnyOpen: Readonly> + /** + * Returns true when the next opening tooltip should skip its open delay. + * + * @remarks + * The skip rule is two-pronged: skip when another tooltip is already open + * in the region, or skip when the previous tooltip closed within the + * `skipDelay` window. + */ + shouldSkipOpenDelay: () => boolean + /** Register an open tooltip with the region. */ + register: (input?: Partial) => RegistryTicket + /** Unregister a tooltip and stamp the close timestamp. */ + unregister: (id: ID) => void +} + +/** + * Creates a new tooltip region context. + * + * @param options Region defaults for delays and disabled state. + * @returns A tooltip region context exposing reactive defaults plus a + * register/unregister pair backed by an internal registry. + * + * @example + * ```ts + * import { createTooltip } from '@vuetify/v0' + * + * const region = createTooltip({ openDelay: 500 }) + * const ticket = region.register({ id: 't:1' }) + * region.shouldSkipOpenDelay() // true while ticket is registered + * region.unregister(ticket.id) + * ``` + */ +export function createTooltip (options: TooltipOptions = {}): TooltipContext { + const openDelay = toRef(() => Number(toValue(options.openDelay) ?? 700)) + const closeDelay = toRef(() => Number(toValue(options.closeDelay) ?? 150)) + const skipDelay = toRef(() => Number(toValue(options.skipDelay) ?? 300)) + const disabled = toRef(() => Boolean(toValue(options.disabled) ?? false)) + + const registry = createRegistry({ reactive: true }) + const lastClosedAt = shallowRef(0) + + const isAnyOpen = toRef(() => registry.size > 0) + + function shouldSkipOpenDelay (): boolean { + if (isAnyOpen.value) return true + if (lastClosedAt.value === 0) return false + return (Date.now() - lastClosedAt.value) < skipDelay.value + } + + function register (input: Partial = {}): RegistryTicket { + return registry.register(input) + } + + function unregister (id: ID): void { + registry.unregister(id) + lastClosedAt.value = Date.now() + } + + return { + openDelay, + closeDelay, + skipDelay, + disabled, + isAnyOpen, + shouldSkipOpenDelay, + register, + unregister, + } +} + +export const [createTooltipContext, createTooltipPlugin, useTooltip] = + createPluginContext( + 'v0:tooltip', + options => createTooltip(options), + ) diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 48508dfd7..2b31238f2 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -191,6 +191,11 @@ "since": "0.1.0", "category": "plugins" }, + "useTooltip": { + "level": "preview", + "since": null, + "category": "plugins" + }, "useClickOutside": { "level": "preview", "since": "0.1.0", From 0c45ee122033e6c6a6b0a9743e95cb2fbcd38eee Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:28:01 -0500 Subject: [PATCH 05/17] refactor(useTooltip): keep createTooltip internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the export of `createTooltip` so the trinity (createTooltipContext, createTooltipPlugin, useTooltip) is the single public entry point — matches the useTheme/useFeatures precedent. The factory stays in the file as the internal builder passed into createPluginContext. --- .../0/src/composables/useTooltip/index.ts | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/0/src/composables/useTooltip/index.ts b/packages/0/src/composables/useTooltip/index.ts index 3f140f21b..7b45f1995 100644 --- a/packages/0/src/composables/useTooltip/index.ts +++ b/packages/0/src/composables/useTooltip/index.ts @@ -70,24 +70,10 @@ export interface TooltipContext { unregister: (id: ID) => void } -/** - * Creates a new tooltip region context. - * - * @param options Region defaults for delays and disabled state. - * @returns A tooltip region context exposing reactive defaults plus a - * register/unregister pair backed by an internal registry. - * - * @example - * ```ts - * import { createTooltip } from '@vuetify/v0' - * - * const region = createTooltip({ openDelay: 500 }) - * const ticket = region.register({ id: 't:1' }) - * region.shouldSkipOpenDelay() // true while ticket is registered - * region.unregister(ticket.id) - * ``` - */ -export function createTooltip (options: TooltipOptions = {}): TooltipContext { +// Internal factory passed to createPluginContext below. The trinity exports +// (`createTooltipContext`, `createTooltipPlugin`, `useTooltip`) are the +// public surface; this factory stays unexported to keep one entry point. +function createTooltip (options: TooltipOptions = {}): TooltipContext { const openDelay = toRef(() => Number(toValue(options.openDelay) ?? 700)) const closeDelay = toRef(() => Number(toValue(options.closeDelay) ?? 150)) const skipDelay = toRef(() => Number(toValue(options.skipDelay) ?? 300)) From 32aba9caac752c55d900a119fe5776a7a69d86c6 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:39:24 -0500 Subject: [PATCH 06/17] feat(Tooltip): add headless compound component family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - — optional scope wrapper for region-level delay overrides, mirrors / - — composes usePopover + useDelay, registers with the useTooltip region for skip-window coordination - — pointer + focus + escape wiring, suppresses touch opens, instant on keyboard focus - — role="tooltip", popover attribute, anchor positioning, optional interactive-content mode ARIA: aria-describedby links activator to content while open; role="tooltip" on content. Touch interactions are suppressed per the WAI-ARIA APG and React Aria policy. The scope wrapper exports as the bare `Tooltip` symbol with the compound sub-components attached via Object.assign — same pattern v0 uses for Popover. --- packages/0/src/components/Tooltip/Tooltip.vue | 82 ++++++++ .../components/Tooltip/TooltipActivator.vue | 139 +++++++++++++ .../src/components/Tooltip/TooltipContent.vue | 95 +++++++++ .../0/src/components/Tooltip/TooltipRoot.vue | 190 ++++++++++++++++++ packages/0/src/components/Tooltip/index.ts | 46 +++++ packages/0/src/components/index.ts | 1 + 6 files changed, 553 insertions(+) create mode 100644 packages/0/src/components/Tooltip/Tooltip.vue create mode 100644 packages/0/src/components/Tooltip/TooltipActivator.vue create mode 100644 packages/0/src/components/Tooltip/TooltipContent.vue create mode 100644 packages/0/src/components/Tooltip/TooltipRoot.vue create mode 100644 packages/0/src/components/Tooltip/index.ts diff --git a/packages/0/src/components/Tooltip/Tooltip.vue b/packages/0/src/components/Tooltip/Tooltip.vue new file mode 100644 index 000000000..745a908cf --- /dev/null +++ b/packages/0/src/components/Tooltip/Tooltip.vue @@ -0,0 +1,82 @@ + + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipActivator.vue b/packages/0/src/components/Tooltip/TooltipActivator.vue new file mode 100644 index 000000000..73804c23b --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipActivator.vue @@ -0,0 +1,139 @@ + + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipContent.vue b/packages/0/src/components/Tooltip/TooltipContent.vue new file mode 100644 index 000000000..36fbc8322 --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipContent.vue @@ -0,0 +1,95 @@ + + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipRoot.vue b/packages/0/src/components/Tooltip/TooltipRoot.vue new file mode 100644 index 000000000..9ee991aa9 --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipRoot.vue @@ -0,0 +1,190 @@ + + + + + + + diff --git a/packages/0/src/components/Tooltip/index.ts b/packages/0/src/components/Tooltip/index.ts new file mode 100644 index 000000000..af5b0ed08 --- /dev/null +++ b/packages/0/src/components/Tooltip/index.ts @@ -0,0 +1,46 @@ +export type { TooltipProps, TooltipSlotProps } from './Tooltip.vue' +export { default as TooltipActivator } from './TooltipActivator.vue' +export { default as TooltipContent } from './TooltipContent.vue' +export { provideTooltipRoot, useTooltipRoot } from './TooltipRoot.vue' +export { default as TooltipRoot } from './TooltipRoot.vue' +export type { TooltipActivatorProps, TooltipActivatorSlotProps } from './TooltipActivator.vue' +export type { TooltipContentProps, TooltipContentSlotProps } from './TooltipContent.vue' +export type { TooltipRootContext, TooltipRootProps, TooltipRootSlotProps } from './TooltipRoot.vue' + +// Components +import TooltipScope from './Tooltip.vue' +import Activator from './TooltipActivator.vue' +import Content from './TooltipContent.vue' +import Root from './TooltipRoot.vue' + +/** + * Tooltip compound component. + * + * Used directly (``) the bare component is the optional scope + * wrapper — it overrides delay defaults for descendants. The compound + * sub-components (`` / `` / + * ``) build the actual tooltip. + * + * @see https://0.vuetifyjs.com/components/disclosure/tooltip + * + * @example + * ```vue + * + * + * + * ``` + */ +export const Tooltip = Object.assign(TooltipScope, { + Root, + Activator, + Content, +}) diff --git a/packages/0/src/components/index.ts b/packages/0/src/components/index.ts index ad5f4a9ec..07fa208ba 100644 --- a/packages/0/src/components/index.ts +++ b/packages/0/src/components/index.ts @@ -36,4 +36,5 @@ export * from './Switch' export * from './Tabs' export * from './Theme' export * from './Toggle' +export * from './Tooltip' export * from './Treeview' From c015433e7f0a7f58eae9617d7d107967bd4d22ad Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:42:05 -0500 Subject: [PATCH 07/17] docs(maturity): register Tooltip + useTooltip as preview --- apps/docs/src/pages/components/index.md | 1 + apps/docs/src/pages/composables/index.md | 1 + packages/0/src/maturity.json | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/docs/src/pages/components/index.md b/apps/docs/src/pages/components/index.md index 4f7239007..b14096f07 100644 --- a/apps/docs/src/pages/components/index.md +++ b/apps/docs/src/pages/components/index.md @@ -97,5 +97,6 @@ Components for showing/hiding content. | [ExpansionPanel](/components/disclosure/expansion-panel) | Accordion-style collapsible panels | | [Popover](/components/disclosure/popover) | CSS anchor-positioned popup content | | [Tabs](/components/disclosure/tabs) | Tab panel navigation with keyboard support and lazy content rendering | +| [Tooltip](/components/disclosure/tooltip) | Description tooltip with hover/focus triggers and shared delay coordination | | [Treeview](/components/disclosure/treeview) | Hierarchical tree with nested selection and expand/collapse | diff --git a/apps/docs/src/pages/composables/index.md b/apps/docs/src/pages/composables/index.md index 5683efd03..f7c63440f 100644 --- a/apps/docs/src/pages/composables/index.md +++ b/apps/docs/src/pages/composables/index.md @@ -302,6 +302,7 @@ Application-level features installable via Vue plugins. | [useStack](/composables/plugins/use-stack) | Overlay z-index stacking with automatic calculation and scrim integration | | [useStorage](/composables/plugins/use-storage) | Reactive browser storage interface | | [useTheme](/composables/plugins/use-theme) | Theme management with CSS custom properties | +| [useTooltip](/composables/plugins/use-tooltip) | Region-scoped tooltip delay coordination plugin | ## Data diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 2b31238f2..7f7d4fb3b 100644 --- a/packages/0/src/maturity.json +++ b/packages/0/src/maturity.json @@ -494,7 +494,8 @@ "category": "semantic" }, "Tooltip": { - "level": "draft", + "level": "preview", + "since": null, "category": "disclosure" }, "Alert": { From c6aa9fe2ba1b2292e1dfa7d3f5a633a5b6e20272 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:45:09 -0500 Subject: [PATCH 08/17] docs(useTooltip): add composable page and basic example --- .../composables/use-tooltip/basic.vue | 48 ++++++++ .../pages/composables/plugins/use-tooltip.md | 107 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 apps/docs/src/examples/composables/use-tooltip/basic.vue create mode 100644 apps/docs/src/pages/composables/plugins/use-tooltip.md diff --git a/apps/docs/src/examples/composables/use-tooltip/basic.vue b/apps/docs/src/examples/composables/use-tooltip/basic.vue new file mode 100644 index 000000000..c0c08735b --- /dev/null +++ b/apps/docs/src/examples/composables/use-tooltip/basic.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/docs/src/pages/composables/plugins/use-tooltip.md b/apps/docs/src/pages/composables/plugins/use-tooltip.md new file mode 100644 index 000000000..9f88a0ae8 --- /dev/null +++ b/apps/docs/src/pages/composables/plugins/use-tooltip.md @@ -0,0 +1,107 @@ +--- +title: useTooltip - Region-Scoped Tooltip Delay Coordination +meta: +- name: description + content: Plugin trinity that coordinates tooltip open/close delays across a region. Holds shared defaults and a skip-window registry so neighboring tooltips open instantly once one is visible. +- name: keywords + content: tooltip, delay, hover, region, plugin, skip window, warmup, Vue 3, composable +features: + category: Composable + label: 'E: useTooltip' + github: /composables/useTooltip/ + level: 2 +related: + - /components/disclosure/tooltip + - /composables/system/use-delay + - /composables/system/use-popover +--- + +# useTooltip + +Region-scoped coordination plugin for tooltip open/close delays. Holds shared `openDelay` / `closeDelay` / `skipDelay` defaults plus a registry of currently-open tooltip tickets so neighboring `` instances can skip their open delay during the warmup window. + + + +## Usage + +```ts collapse +import { createTooltipPlugin, useTooltip } from '@vuetify/v0' + +// App-wide defaults +app.use(createTooltipPlugin({ + openDelay: 500, + closeDelay: 150, + skipDelay: 300, +})) + +// Inside a component +const region = useTooltip() + +region.openDelay.value // 500 +region.isAnyOpen.value // false +region.shouldSkipOpenDelay() // false until a tooltip opens +``` + +`` reads from `useTooltip()` automatically; you do not call this composable yourself unless you're building a non-component consumer. + +## Architecture + +```mermaid "Skip-window coordination" +flowchart LR + T1[Tooltip 1 opens] -- "register()" --> Registry + T2[Tooltip 2 hovers] -- "shouldSkipOpenDelay?" --> Registry + Registry -- "any registered → true" --> Skip[Open instantly] + T1 -- "close()" --> LastClosed[lastClosedAt = now] + T3[Tooltip 3 hovers within skipDelay] -- "shouldSkipOpenDelay?" --> LastClosed + LastClosed -- "now - lastClosedAt < skipDelay" --> Skip +``` + +## Reactivity + +| Property | Type | Description | +|----------|------|-------------| +| `openDelay` | `Readonly>` | Default open delay in ms (700) | +| `closeDelay` | `Readonly>` | Default close delay in ms (150) | +| `skipDelay` | `Readonly>` | Skip-window after last close in ms (300) | +| `disabled` | `Readonly>` | Region-wide disabled flag | +| `isAnyOpen` | `Readonly>` | True when any registered tooltip is currently open | +| `shouldSkipOpenDelay` | `() => boolean` | Whether the next open should bypass the delay | +| `register` | `(input: { id: ID }) => RegistryTicket` | Track a newly-opened tooltip | +| `unregister` | `(id: ID) => void` | Untrack a closed tooltip | + +## Examples + +::: example +/composables/use-tooltip/basic + +### Region inspection + +The example surfaces the live `isAnyOpen` flag and the resolved delay defaults. Click the button to register a synthetic tooltip ticket for one second; rapid clicks keep `isAnyOpen` true and demonstrate how `shouldSkipOpenDelay` behaves through the skip-window after a close. + +Reach for `useTooltip()` directly only when you're wiring a tooltip surface that doesn't go through `` — most consumers should use the component family and let it call this composable internally. + +| File | Role | +|------|------| +| `basic.vue` | Inspects region state and exercises register / unregister | + +::: + +## FAQ + +::: faq + +??? Why is the registry global instead of per-region? + +Skip-window coordination is most useful when neighbors across UI regions cooperate — once any tooltip in the app is open, you want toolbar tooltips and content tooltips to all skip their delay. Splintering the registry per `` scope-wrapper would force consumers to choose between scoped defaults and shared coordination; the current design gives you both. + +??? Can I install useTooltip without using ``? + +Yes. The plugin is just a small shared state object — register and unregister tickets manually if you're building a custom tooltip surface and want it to coordinate with v0 tooltips on the page. + +??? What if I never install the plugin? + +`useTooltip()` returns synthesized fallback defaults (700 / 150 / 300) so `` works without an `app.use(createTooltipPlugin())` call. + +::: + + From dce7cbc1692abf149632db627e7044e906714755 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:48:03 -0500 Subject: [PATCH 09/17] docs(Tooltip): add component page with basic and interactive examples --- .../src/examples/components/tooltip/basic.vue | 21 ++++ .../components/tooltip/interactive.vue | 22 ++++ .../pages/components/disclosure/tooltip.md | 108 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 apps/docs/src/examples/components/tooltip/basic.vue create mode 100644 apps/docs/src/examples/components/tooltip/interactive.vue create mode 100644 apps/docs/src/pages/components/disclosure/tooltip.md diff --git a/apps/docs/src/examples/components/tooltip/basic.vue b/apps/docs/src/examples/components/tooltip/basic.vue new file mode 100644 index 000000000..cadfc3991 --- /dev/null +++ b/apps/docs/src/examples/components/tooltip/basic.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/docs/src/examples/components/tooltip/interactive.vue b/apps/docs/src/examples/components/tooltip/interactive.vue new file mode 100644 index 000000000..ab995a8f0 --- /dev/null +++ b/apps/docs/src/examples/components/tooltip/interactive.vue @@ -0,0 +1,22 @@ + + + diff --git a/apps/docs/src/pages/components/disclosure/tooltip.md b/apps/docs/src/pages/components/disclosure/tooltip.md new file mode 100644 index 000000000..b57f6cf8c --- /dev/null +++ b/apps/docs/src/pages/components/disclosure/tooltip.md @@ -0,0 +1,108 @@ +--- +title: Tooltip - Headless Description Tooltip with Hover and Focus Triggers +meta: +- name: description + content: Headless tooltip component with hover and focus activation, region-scoped delay coordination, configurable interactive-content mode, and WAI-ARIA compliant aria-describedby semantics. +- name: keywords + content: tooltip, hover, focus, popover, ARIA, accessibility, v-bind, slots, Vue 3, headless +features: + category: Component + label: 'C: Tooltip' + github: /components/Tooltip/ + renderless: true + level: 2 +related: + - /composables/plugins/use-tooltip + - /composables/system/use-popover + - /components/disclosure/popover +--- + +# Tooltip + +Headless description tooltip with hover and focus triggers, configurable open/close delays, region-scoped skip-window coordination, and optional interactive-content mode. + + + +## Usage + +::: example +/components/tooltip/basic +::: + +## Anatomy + +```vue playground collapse + +``` + +The bare `` is the optional scope wrapper — it overrides delay defaults for descendants. Skip it when the plugin defaults are sufficient. + +## Architecture + +```mermaid "Tooltip lifecycle" +flowchart LR + Closed -- "pointerenter (mouse) / focus (keyboard)" --> OpenScheduled + OpenScheduled -- "openDelay elapses" --> Open + OpenScheduled -- "skip window active" --> Open + Open -- "pointerleave / blur / click / Escape" --> CloseScheduled + CloseScheduled -- "closeDelay elapses" --> Closed + CloseScheduled -- "pointerenter (interactive content)" --> Open +``` + +## Examples + +::: example +/components/tooltip/interactive + +### Interactive content + +Set `interactive` on `` to let the user move the cursor from the activator into the content without dismissing the tooltip. Useful for tooltips that surface secondary actions or links. + +The strict WAI-ARIA APG tooltip pattern forbids interactive content; if you need a richer hover surface with focusable controls, consider whether a future `HoverCard` component is a better fit. + +| File | Role | +|------|------| +| `interactive.vue` | Demonstrates the `interactive` flag with two action buttons inside the content | + +::: + +## Accessibility + +| Concern | Behavior | +|---------|----------| +| Role | Content renders `role="tooltip"` | +| Linkage | Activator gets `aria-describedby={contentId}` while open | +| Keyboard | Focus opens instantly (no delay), Escape closes, Enter / Space close (the underlying control activates) | +| Touch | Tooltips are not shown on touch interactions per the WAI-ARIA APG | +| Hoverable content | Off by default; opt-in with `interactive` on `` | + +## FAQ + +::: faq + +??? Why don't tooltips show on touch? + +Touch devices have no hover state, and showing a tooltip on tap competes with whatever action the underlying control performs. Both React Aria and the WAI-ARIA Authoring Practices Guide recommend skipping tooltips on touch and ensuring the UI is usable without them. v0 follows this guidance. + +??? How do I share delay defaults across an app? + +Install the plugin: `app.use(createTooltipPlugin({ openDelay: 500 }))`. Every `` reads from the region — wrap a subtree in `` for region-specific overrides. + +??? Why doesn't `` open when I focus it via mouse click? + +The activator suppresses focus-driven opens that arrive within ~50 ms of a `pointerdown`, so a click doesn't double-trigger as both click-close and focus-open. Keyboard-driven focus (Tab) opens instantly. + +??? How do I render a non-button activator? + +`` defaults to `as="button"`; pass `as="a"`, `as="div"`, etc. to render a different element. Always ensure the activator is keyboard-focusable (`tabindex="0"` on a non-button if needed). + +::: + + From a494e6779081088e4d32fb2e0f49ed2009b15d48 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 13:57:02 -0500 Subject: [PATCH 10/17] test(Tooltip): add integration tests; sync READMEs - 5 integration tests covering compound shape, open/close timing, touch suppression, aria-describedby linkage, and disabled gating - Fix usePopover.attach onMounted path to use element?.showPopover?.() (matches the watcher's defensive pattern at line 148; surfaced by the aria-describedby test which mounts with defaultOpen: true) - Sync packages/0/README.md and root README.md with Tooltip and useTooltip entries --- packages/0/README.md | 2 + .../0/src/components/Tooltip/index.test.ts | 123 ++++++++++++++++++ .../0/src/composables/usePopover/index.ts | 2 +- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/0/src/components/Tooltip/index.test.ts diff --git a/packages/0/README.md b/packages/0/README.md index 7ab5a5844..b9ce5d0ab 100644 --- a/packages/0/README.md +++ b/packages/0/README.md @@ -130,6 +130,7 @@ import { ... } from '@vuetify/v0/date' // Date adapter and utilities | [ExpansionPanel](https://0.vuetifyjs.com/components/disclosure/expansion-panel) | Accordion-style collapsible panels | | [Popover](https://0.vuetifyjs.com/components/disclosure/popover) | CSS anchor-positioned popup content | | [Tabs](https://0.vuetifyjs.com/components/disclosure/tabs) | Tab panel navigation with keyboard support and lazy content rendering | +| [Tooltip](https://0.vuetifyjs.com/components/disclosure/tooltip) | Description tooltip with hover/focus triggers | | [Treeview](https://0.vuetifyjs.com/components/disclosure/treeview) | Hierarchical tree with nested selection and expand/collapse | #### Semantic @@ -247,6 +248,7 @@ Plugin-capable composables following the trinity pattern: - [`useStack`](https://0.vuetifyjs.com/composables/plugins/use-stack) - Overlay z-index stacking with automatic scrim coordination - [`useStorage`](https://0.vuetifyjs.com/composables/plugins/use-storage) - Storage adapter (localStorage/sessionStorage/memory) - [`useTheme`](https://0.vuetifyjs.com/composables/plugins/use-theme) - Theme management with CSS variable injection +- [`useTooltip`](https://0.vuetifyjs.com/composables/plugins/use-tooltip) - Region-scoped tooltip delay coordination ## Design Principles diff --git a/packages/0/src/components/Tooltip/index.test.ts b/packages/0/src/components/Tooltip/index.test.ts new file mode 100644 index 000000000..06a3e827d --- /dev/null +++ b/packages/0/src/components/Tooltip/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Composables +import { createTooltipPlugin } from '#v0/composables/useTooltip' + +// Utilities +import { mount } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' + +import { Tooltip } from './index' + +const global = { plugins: [createTooltipPlugin()] } + +describe('tooltip', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('compound shape', () => { + it('should expose Root, Activator, Content as compound members', () => { + expect(Tooltip.Root).toBeDefined() + expect(Tooltip.Activator).toBeDefined() + expect(Tooltip.Content).toBeDefined() + }) + }) + + describe('open and close', () => { + it('should open after openDelay on activator pointerenter', async () => { + const Harness = defineComponent({ + components: { TR: Tooltip.Root, TA: Tooltip.Activator, TC: Tooltip.Content }, + setup () { + return () => + h(Tooltip.Root, { openDelay: 300, closeDelay: 100 }, () => [ + h(Tooltip.Activator, null, () => 'Trigger'), + h(Tooltip.Content, null, () => 'Tip'), + ]) + }, + }) + + const wrapper = mount(Harness, { attachTo: document.body, global }) + const activator = wrapper.find('button') + await activator.trigger('pointerenter', { pointerType: 'mouse' }) + + expect(activator.attributes('data-state')).toBe('closed') + + vi.advanceTimersByTime(300) + await nextTick() + + expect(activator.attributes('data-state')).toBe('delayed-open') + wrapper.unmount() + }) + + it('should suppress open on touch pointerenter', async () => { + const wrapper = mount(defineComponent({ + setup () { + return () => + h(Tooltip.Root, { openDelay: 200 }, () => [ + h(Tooltip.Activator, null, () => 'Trigger'), + h(Tooltip.Content, null, () => 'Tip'), + ]) + }, + }), { attachTo: document.body, global }) + + await wrapper.find('button').trigger('pointerenter', { pointerType: 'touch' }) + vi.advanceTimersByTime(500) + await nextTick() + + expect(wrapper.find('button').attributes('data-state')).toBe('closed') + wrapper.unmount() + }) + }) + + describe('aria-describedby', () => { + it('should link activator to content while open', async () => { + const wrapper = mount(defineComponent({ + setup () { + return () => + h(Tooltip.Root, { defaultOpen: true }, () => [ + h(Tooltip.Activator, null, () => 'Trigger'), + h(Tooltip.Content, null, () => 'Tip'), + ]) + }, + }), { attachTo: document.body, global }) + + await nextTick() + + const activator = wrapper.find('button') + const describedBy = activator.attributes('aria-describedby') + expect(describedBy).toBeDefined() + + // The id matches the content's id + const content = wrapper.find('[role="tooltip"]') + expect(content.attributes('id')).toBe(describedBy) + + wrapper.unmount() + }) + }) + + describe('disabled', () => { + it('should not open when disabled', async () => { + const wrapper = mount(defineComponent({ + setup () { + return () => + h(Tooltip.Root, { disabled: true, openDelay: 100 }, () => [ + h(Tooltip.Activator, null, () => 'Trigger'), + h(Tooltip.Content, null, () => 'Tip'), + ]) + }, + }), { attachTo: document.body, global }) + + await wrapper.find('button').trigger('pointerenter', { pointerType: 'mouse' }) + vi.advanceTimersByTime(500) + await nextTick() + + expect(wrapper.find('button').attributes('data-state')).toBe('closed') + wrapper.unmount() + }) + }) +}) diff --git a/packages/0/src/composables/usePopover/index.ts b/packages/0/src/composables/usePopover/index.ts index 6ee754996..4f1baafdb 100644 --- a/packages/0/src/composables/usePopover/index.ts +++ b/packages/0/src/composables/usePopover/index.ts @@ -135,7 +135,7 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { onMounted(() => { const element = toValue(el) if (isOpen.value) { - element?.showPopover() + element?.showPopover?.() } }) From 2a9e52c13f0f58b085fec685f8a34d83fb2e100b Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 14:48:07 -0500 Subject: [PATCH 11/17] refactor(useTooltip): inspect-loop hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes surfaced by /inspect --thorough: Source: - Use performance.now() for skip-window math (gated on IN_BROWSER) to avoid wall-clock rollback / DST corruption hazards - Switch lastClosedAt sentinel from `0` to `null` so the perf.now() near-zero start can't collide with the never-closed marker; guard with isNull() and an elapsed >= 0 defense against fake-timer rewind - Short-circuit unregister() on !registry.has(id) so a stray / duplicate unregister can't silently corrupt the skip-window by stamping a fresh lastClosedAt - Drop Number()/Boolean() coercion on reactive options — the MaybeRefOrGetter typing already promises the value type - Add TooltipContextOptions / TooltipPluginOptions interface split matching the useTheme precedent - Pass createTooltip directly to createPluginContext (no thunk wrap) - Tighten register() parameter to Partial, matching createRegistry's actual signature - Add onScopeDispose(() => registry.dispose()) gated on getCurrentScope() so component-scope and effectScope() callers get cleanup while the plugin install path stays scope-warning-free - Per-symbol @example blocks on every TooltipContext member + TooltipOptions, per the composables.md "100% enforced" rule Tests: - All createTooltipContext() calls in tests now use a `test:` namespace prefix per testing.md discipline - Replace the misleading `namespace` describe block (which only tested options pass-through) with a real cross-namespace isolation test - Hoist Vue + ./index imports in the plugin-install test (no more await import) --- .../src/composables/useTooltip/index.test.ts | 33 +++-- .../0/src/composables/useTooltip/index.ts | 136 +++++++++++++++--- 2 files changed, 133 insertions(+), 36 deletions(-) diff --git a/packages/0/src/composables/useTooltip/index.test.ts b/packages/0/src/composables/useTooltip/index.test.ts index c2d601a45..ae6a4438b 100644 --- a/packages/0/src/composables/useTooltip/index.test.ts +++ b/packages/0/src/composables/useTooltip/index.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Utilities -import { effectScope } from 'vue' +import { createApp, defineComponent, effectScope, h } from 'vue' // Composables -import { createTooltipContext, createTooltipPlugin } from './index' +import { createTooltipContext, createTooltipPlugin, useTooltip } from './index' describe('useTooltip', () => { beforeEach(() => { @@ -19,7 +19,7 @@ describe('useTooltip', () => { it('should expose default delays', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext() + const [,, ctx] = createTooltipContext({ namespace: 'test:tooltip' }) expect(ctx.openDelay.value).toBe(700) expect(ctx.closeDelay.value).toBe(150) expect(ctx.skipDelay.value).toBe(300) @@ -32,6 +32,7 @@ describe('useTooltip', () => { const scope = effectScope() scope.run(() => { const [,, ctx] = createTooltipContext({ + namespace: 'test:tooltip', openDelay: 500, closeDelay: 200, skipDelay: 400, @@ -50,7 +51,7 @@ describe('useTooltip', () => { it('should track open tooltips via register / unregister', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext() + const [,, ctx] = createTooltipContext({ namespace: 'test:tooltip' }) expect(ctx.isAnyOpen.value).toBe(false) const ticket = ctx.register({ id: 't:1' }) @@ -67,7 +68,7 @@ describe('useTooltip', () => { it('should skip open delay when another tooltip is open', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext() + const [,, ctx] = createTooltipContext({ namespace: 'test:tooltip' }) const ticket = ctx.register({ id: 't:1' }) expect(ctx.shouldSkipOpenDelay()).toBe(true) @@ -79,7 +80,7 @@ describe('useTooltip', () => { it('should skip open delay within skipDelay window after last close', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext({ skipDelay: 300 }) + const [,, ctx] = createTooltipContext({ namespace: 'test:tooltip', skipDelay: 300 }) const ticket = ctx.register({ id: 't:1' }) ctx.unregister(ticket.id) @@ -95,7 +96,7 @@ describe('useTooltip', () => { it('should not skip when no tooltips have ever opened', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext({ skipDelay: 300 }) + const [,, ctx] = createTooltipContext({ namespace: 'test:tooltip', skipDelay: 300 }) expect(ctx.shouldSkipOpenDelay()).toBe(false) }) scope.stop() @@ -103,10 +104,7 @@ describe('useTooltip', () => { }) describe('plugin install', () => { - it('should expose useTooltip after app.use(createTooltipPlugin())', async () => { - const { createApp, defineComponent, h } = await import('vue') - const { useTooltip } = await import('./index') - + it('should expose useTooltip after app.use(createTooltipPlugin())', () => { let captured: ReturnType | undefined const Probe = defineComponent({ @@ -128,12 +126,17 @@ describe('useTooltip', () => { }) describe('namespace', () => { - it('should support component-level provideTooltipContext for region scoping', () => { - // Verify the trinity exports a provider symbol for region overrides + it('should isolate contexts across namespaces', () => { const scope = effectScope() scope.run(() => { - const [,, ctx] = createTooltipContext({ openDelay: 100, namespace: 'test:tooltip' }) - expect(ctx.openDelay.value).toBe(100) + const [,, ctxA] = createTooltipContext({ namespace: 'test:tooltip-a', openDelay: 100 }) + const [,, ctxB] = createTooltipContext({ namespace: 'test:tooltip-b', openDelay: 200 }) + expect(ctxA.openDelay.value).toBe(100) + expect(ctxB.openDelay.value).toBe(200) + // Confirm registries are independent + ctxA.register({ id: 'tooltip-a:1' }) + expect(ctxA.isAnyOpen.value).toBe(true) + expect(ctxB.isAnyOpen.value).toBe(false) }) scope.stop() }) diff --git a/packages/0/src/composables/useTooltip/index.ts b/packages/0/src/composables/useTooltip/index.ts index 7b45f1995..50c129f05 100644 --- a/packages/0/src/composables/useTooltip/index.ts +++ b/packages/0/src/composables/useTooltip/index.ts @@ -26,13 +26,31 @@ import { createPluginContext } from '#v0/composables/createPlugin' import { createRegistry } from '#v0/composables/createRegistry' // Utilities -import { shallowRef, toRef, toValue } from 'vue' +import { isNull } from '#v0/utilities' +import { getCurrentScope, onScopeDispose, shallowRef, toRef, toValue } from 'vue' // Types -import type { RegistryTicket, RegistryTicketInput } from '#v0/composables/createRegistry' +import type { RegistryTicket } from '#v0/composables/createRegistry' import type { ID } from '#v0/types' import type { MaybeRefOrGetter, Ref } from 'vue' +// Globals +import { IN_BROWSER } from '#v0/constants/globals' + +function now (): number { + return IN_BROWSER ? performance.now() : Date.now() +} + +/** + * Options accepted by the tooltip factory. + * + * @example + * ```ts + * import { createTooltipPlugin } from '@vuetify/v0' + * + * app.use(createTooltipPlugin({ openDelay: 500, closeDelay: 100 })) + * ``` + */ export interface TooltipOptions { /** Default open delay in ms. @default 700 */ openDelay?: MaybeRefOrGetter @@ -45,15 +63,55 @@ export interface TooltipOptions { } export interface TooltipContext { - /** Reactive open delay in ms. */ + /** + * Reactive open delay in ms. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.openDelay.value) + * ``` + */ openDelay: Readonly> - /** Reactive close delay in ms. */ + /** + * Reactive close delay in ms. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.closeDelay.value) + * ``` + */ closeDelay: Readonly> - /** Reactive skip-window length in ms. */ + /** + * Reactive skip-window length in ms. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.skipDelay.value) + * ``` + */ skipDelay: Readonly> - /** Reactive disabled flag for the region. */ + /** + * Reactive disabled flag for the region. + * + * @example + * ```ts + * const tooltip = useTooltip() + * if (tooltip.disabled.value) return + * ``` + */ disabled: Readonly> - /** True when at least one tooltip is currently open in this region. */ + /** + * True when at least one tooltip is currently open in this region. + * + * @example + * ```ts + * const tooltip = useTooltip() + * watch(tooltip.isAnyOpen, open => console.log(open)) + * ``` + */ isAnyOpen: Readonly> /** * Returns true when the next opening tooltip should skip its open delay. @@ -62,41 +120,77 @@ export interface TooltipContext { * The skip rule is two-pronged: skip when another tooltip is already open * in the region, or skip when the previous tooltip closed within the * `skipDelay` window. + * + * @example + * ```ts + * const tooltip = useTooltip() + * const delay = tooltip.shouldSkipOpenDelay() ? 0 : tooltip.openDelay.value + * ``` */ shouldSkipOpenDelay: () => boolean - /** Register an open tooltip with the region. */ - register: (input?: Partial) => RegistryTicket - /** Unregister a tooltip and stamp the close timestamp. */ + /** + * Register an open tooltip with the region. + * + * @example + * ```ts + * const tooltip = useTooltip() + * const ticket = tooltip.register({ id: 'tooltip:1' }) + * ``` + */ + register: (input?: Partial) => RegistryTicket + /** + * Unregister a tooltip and stamp the close timestamp. + * + * @example + * ```ts + * const tooltip = useTooltip() + * tooltip.unregister(ticket.id) + * ``` + */ unregister: (id: ID) => void } +export interface TooltipContextOptions extends TooltipOptions { + namespace?: string +} + +export interface TooltipPluginOptions extends TooltipContextOptions { + // No persist field today; declared for parity with sibling plugins. +} + // Internal factory passed to createPluginContext below. The trinity exports // (`createTooltipContext`, `createTooltipPlugin`, `useTooltip`) are the // public surface; this factory stays unexported to keep one entry point. function createTooltip (options: TooltipOptions = {}): TooltipContext { - const openDelay = toRef(() => Number(toValue(options.openDelay) ?? 700)) - const closeDelay = toRef(() => Number(toValue(options.closeDelay) ?? 150)) - const skipDelay = toRef(() => Number(toValue(options.skipDelay) ?? 300)) - const disabled = toRef(() => Boolean(toValue(options.disabled) ?? false)) + const openDelay = toRef(() => toValue(options.openDelay) ?? 700) + const closeDelay = toRef(() => toValue(options.closeDelay) ?? 150) + const skipDelay = toRef(() => toValue(options.skipDelay) ?? 300) + const disabled = toRef(() => toValue(options.disabled) ?? false) const registry = createRegistry({ reactive: true }) - const lastClosedAt = shallowRef(0) + const lastClosedAt = shallowRef(null) const isAnyOpen = toRef(() => registry.size > 0) function shouldSkipOpenDelay (): boolean { if (isAnyOpen.value) return true - if (lastClosedAt.value === 0) return false - return (Date.now() - lastClosedAt.value) < skipDelay.value + if (isNull(lastClosedAt.value)) return false + const elapsed = now() - lastClosedAt.value + return elapsed >= 0 && elapsed < skipDelay.value } - function register (input: Partial = {}): RegistryTicket { + function register (input?: Partial): RegistryTicket { return registry.register(input) } function unregister (id: ID): void { + if (!registry.has(id)) return registry.unregister(id) - lastClosedAt.value = Date.now() + lastClosedAt.value = now() + } + + if (getCurrentScope()) { + onScopeDispose(() => registry.dispose()) } return { @@ -112,7 +206,7 @@ function createTooltip (options: TooltipOptions = {}): TooltipContext { } export const [createTooltipContext, createTooltipPlugin, useTooltip] = - createPluginContext( + createPluginContext( 'v0:tooltip', - options => createTooltip(options), + createTooltip, ) From 0e9242027f68b4b45af9015504e8de47c78539c8 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 15:19:23 -0500 Subject: [PATCH 12/17] docs(zero): install createTooltipPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tooltip docs page mounts which calls useTooltip() to read region-scoped delay defaults. Without app.use(createTooltipPlugin()) the trinity throws "Context 'v0:tooltip' not found" and the page fails to render — caught only by hitting the page in a real browser, not by typecheck/lint/build. Verified live: hover the basic example activator, content renders with role='tooltip', activator gets aria-describedby linked to the content id, data-state transitions closed -> delayed-open. --- apps/docs/src/plugins/zero.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/src/plugins/zero.ts b/apps/docs/src/plugins/zero.ts index 1d9d50e8d..42fbd9e43 100644 --- a/apps/docs/src/plugins/zero.ts +++ b/apps/docs/src/plugins/zero.ts @@ -1,5 +1,5 @@ // Framework -import { createBreakpointsPlugin, createDatePlugin, createFeaturesPlugin, createHydrationPlugin, createLocalePlugin, createLoggerPlugin, createPermissionsPlugin, createRtlPlugin, createStackPlugin, createStoragePlugin, createThemePlugin, IN_BROWSER, useFeatures, V0UnheadThemeAdapter } from '@vuetify/v0' +import { createBreakpointsPlugin, createDatePlugin, createFeaturesPlugin, createHydrationPlugin, createLocalePlugin, createLoggerPlugin, createPermissionsPlugin, createRtlPlugin, createStackPlugin, createStoragePlugin, createThemePlugin, createTooltipPlugin, IN_BROWSER, useFeatures, V0UnheadThemeAdapter } from '@vuetify/v0' import { V0DateAdapter } from '@vuetify/v0/date' // Composables @@ -22,6 +22,7 @@ export default function zero (app: App) { app.use(createBreakpointsPlugin({ mobileBreakpoint: 768 })) app.use(createStoragePlugin()) app.use(createStackPlugin()) + app.use(createTooltipPlugin()) app.use(createDiscoveryPlugin()) app.use( From aeefb9b9de4cd93a1f33195907f54b77e6be2cfb Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 22:12:20 -0500 Subject: [PATCH 13/17] fix(Tooltip): apply anchorStyles to the activator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS-anchor-positioning chain needs three pieces: - 'anchor-name' on the activator (CSS custom prop) - 'position-anchor' on the content referencing it - 'position-area' on the content The content side had both, the activator side had neither — so the content's position-anchor pointed at a non-existent anchor-name and the popover fell back to inset:0 (renders pinned to viewport bottom). Mirrors PopoverActivator's :style binding to context.anchorStyles. --- packages/0/src/components/Tooltip/TooltipActivator.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/0/src/components/Tooltip/TooltipActivator.vue b/packages/0/src/components/Tooltip/TooltipActivator.vue index 73804c23b..158a4b0f3 100644 --- a/packages/0/src/components/Tooltip/TooltipActivator.vue +++ b/packages/0/src/components/Tooltip/TooltipActivator.vue @@ -132,6 +132,7 @@ From 7caec369fc8c3c8e8d345157760ab4c5fb2be0e0 Mon Sep 17 00:00:00 2001 From: John Leider Date: Fri, 1 May 2026 22:12:26 -0500 Subject: [PATCH 14/17] docs(useTooltip): swap synthetic register button for real coordinated tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous example mounted a single Button and asked the reader to click it to fire register/unregister manually. That's not how anyone uses useTooltip — it teaches the registry primitive instead of the behavior consumers actually want, which is skip-window coordination across multiple Tooltip.Root instances. New example renders four Tooltip.Root buttons in a row plus the live isAnyOpen / openDelay / skipDelay badges. Hover the first — full delay. Move to a neighbor while one is open — instant. Idle past skipDelay — full delay returns. Demonstrates the actual contract. --- .../composables/use-tooltip/basic.vue | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/docs/src/examples/composables/use-tooltip/basic.vue b/apps/docs/src/examples/composables/use-tooltip/basic.vue index c0c08735b..8601ee01e 100644 --- a/apps/docs/src/examples/composables/use-tooltip/basic.vue +++ b/apps/docs/src/examples/composables/use-tooltip/basic.vue @@ -1,21 +1,14 @@ From d2799e8f530742789bd03e4bbbcc46e6cf0efc13 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sun, 3 May 2026 19:13:50 -0500 Subject: [PATCH 15/17] fix(useTooltip): wire fallback factory and tighten register input Resolves four findings from a thorough inspection of the useTooltip plugin composable. - Add createTooltipFallback() and pass it to createPluginContext so useTooltip() returns synthesized defaults without app.use(createTooltipPlugin()) installed, matching the docs FAQ promise and the useLogger/useLocale/ useHydration sibling pattern. - Narrow register input from Partial (output shape) to Partial (input shape) per the FooTicketInput pair invariant. - Replace IN_BROWSER-gated now() helper with unconditional performance.now(); the API is global in Node 22+, browsers, and edge runtimes. - Convert empty TooltipPluginOptions interface placeholder to a type alias. --- .../0/src/composables/useTooltip/index.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/0/src/composables/useTooltip/index.ts b/packages/0/src/composables/useTooltip/index.ts index 50c129f05..5490e7651 100644 --- a/packages/0/src/composables/useTooltip/index.ts +++ b/packages/0/src/composables/useTooltip/index.ts @@ -30,17 +30,10 @@ import { isNull } from '#v0/utilities' import { getCurrentScope, onScopeDispose, shallowRef, toRef, toValue } from 'vue' // Types -import type { RegistryTicket } from '#v0/composables/createRegistry' +import type { RegistryTicket, RegistryTicketInput } from '#v0/composables/createRegistry' import type { ID } from '#v0/types' import type { MaybeRefOrGetter, Ref } from 'vue' -// Globals -import { IN_BROWSER } from '#v0/constants/globals' - -function now (): number { - return IN_BROWSER ? performance.now() : Date.now() -} - /** * Options accepted by the tooltip factory. * @@ -137,7 +130,7 @@ export interface TooltipContext { * const ticket = tooltip.register({ id: 'tooltip:1' }) * ``` */ - register: (input?: Partial) => RegistryTicket + register: (input?: Partial) => RegistryTicket /** * Unregister a tooltip and stamp the close timestamp. * @@ -154,9 +147,7 @@ export interface TooltipContextOptions extends TooltipOptions { namespace?: string } -export interface TooltipPluginOptions extends TooltipContextOptions { - // No persist field today; declared for parity with sibling plugins. -} +export type TooltipPluginOptions = TooltipContextOptions // Internal factory passed to createPluginContext below. The trinity exports // (`createTooltipContext`, `createTooltipPlugin`, `useTooltip`) are the @@ -175,18 +166,18 @@ function createTooltip (options: TooltipOptions = {}): TooltipContext { function shouldSkipOpenDelay (): boolean { if (isAnyOpen.value) return true if (isNull(lastClosedAt.value)) return false - const elapsed = now() - lastClosedAt.value + const elapsed = performance.now() - lastClosedAt.value return elapsed >= 0 && elapsed < skipDelay.value } - function register (input?: Partial): RegistryTicket { + function register (input?: Partial): RegistryTicket { return registry.register(input) } function unregister (id: ID): void { if (!registry.has(id)) return registry.unregister(id) - lastClosedAt.value = now() + lastClosedAt.value = performance.now() } if (getCurrentScope()) { @@ -205,8 +196,29 @@ function createTooltip (options: TooltipOptions = {}): TooltipContext { } } +/** + * Synthesized fallback used when `useTooltip()` is called without + * `app.use(createTooltipPlugin())`. Returns a fresh context with the + * documented defaults — `` keeps working but warmup + * coordination is per-instance instead of region-wide. + * + * @example + * ```ts + * import { createTooltipFallback } from '@vuetify/v0' + * + * const tooltip = createTooltipFallback() + * console.log(tooltip.openDelay.value) // 700 + * ``` + */ +export function createTooltipFallback (): TooltipContext { + return createTooltip() +} + export const [createTooltipContext, createTooltipPlugin, useTooltip] = createPluginContext( 'v0:tooltip', createTooltip, + { + fallback: () => createTooltipFallback(), + }, ) From 2bf3c964fe70a6634017410431cc05cc111eb871 Mon Sep 17 00:00:00 2001 From: John Leider Date: Sun, 3 May 2026 20:41:21 -0500 Subject: [PATCH 16/17] chore(TooltipRoot): updates --- packages/0/src/components/Tooltip/TooltipRoot.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/0/src/components/Tooltip/TooltipRoot.vue b/packages/0/src/components/Tooltip/TooltipRoot.vue index 9ee991aa9..5a907bc68 100644 --- a/packages/0/src/components/Tooltip/TooltipRoot.vue +++ b/packages/0/src/components/Tooltip/TooltipRoot.vue @@ -21,6 +21,7 @@ import { useTooltip } from '#v0/composables/useTooltip' // Utilities + import { isUndefined } from '#v0/utilities' import { onBeforeUnmount, shallowRef, toRef, watch } from 'vue' // Types @@ -91,7 +92,7 @@ const isDisabled = toRef(() => disabled || region.disabled.value) const isInteractive = toRef(() => interactive) - const isOpen: Ref = model.value === undefined + const isOpen: Ref = isUndefined(model.value) ? shallowRef(defaultOpen) : (model as Ref) @@ -130,17 +131,17 @@ let ticketId: ID | undefined watch(isOpen, value => { - if (value && ticketId === undefined) { + if (value && isUndefined(ticketId)) { const ticket = region.register({ id: popover.id }) ticketId = ticket.id - } else if (!value && ticketId !== undefined) { + } else if (!value && !isUndefined(ticketId)) { region.unregister(ticketId) ticketId = undefined } }, { immediate: true }) onBeforeUnmount(() => { - if (ticketId !== undefined) region.unregister(ticketId) + if (!isUndefined(ticketId)) region.unregister(ticketId) }) const dataState = toRef((): 'open' | 'closed' | 'delayed-open' | 'instant-open' => { From 949bfffd9897337d7bc4041d28046cbabd563c8e Mon Sep 17 00:00:00 2001 From: John Leider Date: Sun, 10 May 2026 00:14:00 -0500 Subject: [PATCH 17/17] fix(Tooltip): correctness and convention fixes - aria-describedby on activator is always set so screen readers announce the description on focus (content is mounted regardless of open state) - defineModel canonical pattern: unnamed v-model with default false; drop defaultOpen prop in favor of one-way :model-value or v-model - region-disable race: re-check isDisabled inside the useDelay callback so flipping region.disabled mid-delay no longer opens the tooltip - AtomExpose consumed via toElement-routed ref per the documented pattern - defineEmits('update:model-value') alongside defineModel for vue-devtools - useDocumentEventListener for the document-level Escape handler - ShallowRef context typing for isOpen, matching Dialog/AlertDialog - dataState/dataSide unions narrowed to actually-emitted values; dataSide returns undefined for non-physical CSS sides - internal handlers renamed open/close/cancel (was _open/_close/_cancel) - structurally-typed slot attrs and styles for better consumer DX - @module JSDoc converted to /** */ form (HTML-token-free content) to match Theme/Locale/Dialog precedent - test: namespace prefix test: -> v0:test- (zero-stderr policy); vi.useRealTimers() in afterEach prevents fake-timer leakage - docs: anatomy fence wraps script setup so playground links compile; FAQ headers strip inline backticks; register signature aligned with source; broken self-dispatching click handler removed from interactive --- .../components/tooltip/interactive.vue | 2 +- .../pages/components/disclosure/tooltip.md | 14 ++-- .../pages/composables/plugins/use-tooltip.md | 2 +- packages/0/src/components/Tooltip/Tooltip.vue | 23 ++++--- .../components/Tooltip/TooltipActivator.vue | 50 +++++++------- .../src/components/Tooltip/TooltipContent.vue | 41 +++++++---- .../0/src/components/Tooltip/TooltipRoot.vue | 68 +++++++++---------- .../0/src/components/Tooltip/index.test.ts | 3 +- .../src/composables/useTooltip/index.test.ts | 17 ++--- 9 files changed, 119 insertions(+), 101 deletions(-) diff --git a/apps/docs/src/examples/components/tooltip/interactive.vue b/apps/docs/src/examples/components/tooltip/interactive.vue index ab995a8f0..45cc8f0b7 100644 --- a/apps/docs/src/examples/components/tooltip/interactive.vue +++ b/apps/docs/src/examples/components/tooltip/interactive.vue @@ -14,7 +14,7 @@ - + diff --git a/apps/docs/src/pages/components/disclosure/tooltip.md b/apps/docs/src/pages/components/disclosure/tooltip.md index b57f6cf8c..cd2eb9e0b 100644 --- a/apps/docs/src/pages/components/disclosure/tooltip.md +++ b/apps/docs/src/pages/components/disclosure/tooltip.md @@ -31,7 +31,11 @@ Headless description tooltip with hover and focus triggers, configurable open/cl ## Anatomy -```vue playground collapse +```vue Anatomy playground + +