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..45cc8f0b7 --- /dev/null +++ b/apps/docs/src/examples/components/tooltip/interactive.vue @@ -0,0 +1,22 @@ + + + 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..8601ee01e --- /dev/null +++ b/apps/docs/src/examples/composables/use-tooltip/basic.vue @@ -0,0 +1,51 @@ + + + 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..cd2eb9e0b --- /dev/null +++ b/apps/docs/src/pages/components/disclosure/tooltip.md @@ -0,0 +1,112 @@ +--- +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 Anatomy playground + + + +``` + +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 always carries `aria-describedby={contentId}` so screen readers announce the description on focus | +| Keyboard | Focus opens instantly (no delay), Escape closes; Enter / Space activate the underlying control, which closes via click | +| 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 Tooltip.Activator 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? + +The 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). + +::: + + 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/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..bb4189072 --- /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?: Partial) => 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. + +::: + + 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/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( 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/Tooltip.vue b/packages/0/src/components/Tooltip/Tooltip.vue new file mode 100644 index 000000000..8a3f72501 --- /dev/null +++ b/packages/0/src/components/Tooltip/Tooltip.vue @@ -0,0 +1,85 @@ +/** + * @module Tooltip + * + * @see https://0.vuetifyjs.com/components/disclosure/tooltip + * + * @remarks + * Scoped tooltip-defaults provider. Mirrors the Theme and Locale providers. + * Overrides openDelay, closeDelay, skipDelay, and disabled for descendants; + * the underlying registry and warmup window are shared with the parent + * context (and ultimately with the plugin), so a single open tooltip primes + * warmup across every scope. Optional — Tooltip.Root works without a wrapper + * when the plugin defaults are sufficient. + */ + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipActivator.vue b/packages/0/src/components/Tooltip/TooltipActivator.vue new file mode 100644 index 000000000..2f881c781 --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipActivator.vue @@ -0,0 +1,138 @@ +/** + * @module TooltipActivator + * + * @see https://0.vuetifyjs.com/components/disclosure/tooltip + * + * @remarks + * Tooltip trigger element. Binds pointer, focus, and escape events and + * exposes them on slot attrs for renderless usage. Touch interactions + * are suppressed per WAI-ARIA APG. Keyboard focus respects openDelay + * the same as hover, but is exempt from the pointerdown suppression + * window so a click that incidentally moves focus does not double-trigger. + */ + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipContent.vue b/packages/0/src/components/Tooltip/TooltipContent.vue new file mode 100644 index 000000000..5d5022f74 --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipContent.vue @@ -0,0 +1,108 @@ +/** + * @module TooltipContent + * + * @see https://0.vuetifyjs.com/components/disclosure/tooltip + * + * @remarks + * Tooltip content surface. Renders role="tooltip", the native popover + * attribute, anchor positioning styles, and direction data attributes. + * In interactive mode (set on Tooltip.Root), pointer enter/leave handlers + * cancel and re-arm the close timer so the user can move into the content + * without dismissing it. + */ + + + + + + diff --git a/packages/0/src/components/Tooltip/TooltipRoot.vue b/packages/0/src/components/Tooltip/TooltipRoot.vue new file mode 100644 index 000000000..b84a60a0d --- /dev/null +++ b/packages/0/src/components/Tooltip/TooltipRoot.vue @@ -0,0 +1,189 @@ +/** + * @module TooltipRoot + * + * @see https://0.vuetifyjs.com/components/disclosure/tooltip + * + * @remarks + * Compound root for a single tooltip instance. Composes usePopover for + * state and anchor positioning, layers useDelay for region-aware open + * and close transitions, and registers with the useTooltip region context + * for skip-window coordination. + */ + + + + + + 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..24692740a --- /dev/null +++ b/packages/0/src/components/Tooltip/index.test.ts @@ -0,0 +1,124 @@ +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.useRealTimers() + 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, { modelValue: 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/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' 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. 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/usePopover/index.test.ts b/packages/0/src/composables/usePopover/index.test.ts index ff32699cf..699057ea7 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,33 @@ describe('usePopover', () => { }) }) + describe('cancel', () => { + it('should cancel a pending open transition', () => { + 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() + }) + }) + describe('anchorStyles', () => { it('should generate anchor-name from id', () => { const popover = usePopover({ id: 'test' }) @@ -177,7 +204,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..4f1baafdb 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}`, })) @@ -149,13 +135,12 @@ export function usePopover (options: PopoverOptions = {}): PopoverReturn { onMounted(() => { const element = toValue(el) if (isOpen.value) { - element?.showPopover() + element?.showPopover?.() } }) 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, 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..c57ab6f72 --- /dev/null +++ b/packages/0/src/composables/useTooltip/index.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Utilities +import { createApp, defineComponent, effectScope, h } from 'vue' + +// Composables +import { createTooltipContext, createTooltipPlugin, useTooltip } from './index' + +describe('useTooltip', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + describe('defaults', () => { + it('should expose default delays', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctx] = createTooltipContext({ namespace: 'v0:test-tooltip' }) + 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({ + namespace: 'v0:test-tooltip', + 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({ namespace: 'v0:test-tooltip' }) + 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({ namespace: 'v0:test-tooltip' }) + 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({ namespace: 'v0:test-tooltip', 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({ namespace: 'v0:test-tooltip', skipDelay: 300 }) + expect(ctx.shouldSkipOpenDelay()).toBe(false) + }) + scope.stop() + }) + }) + + describe('plugin install', () => { + it('should expose useTooltip after app.use(createTooltipPlugin())', () => { + 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 isolate contexts across namespaces', () => { + const scope = effectScope() + scope.run(() => { + const [,, ctxA] = createTooltipContext({ namespace: 'v0:test-tooltip-a', openDelay: 100 }) + const [,, ctxB] = createTooltipContext({ namespace: 'v0: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 new file mode 100644 index 000000000..5490e7651 --- /dev/null +++ b/packages/0/src/composables/useTooltip/index.ts @@ -0,0 +1,224 @@ +/** + * @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 { isNull } from '#v0/utilities' +import { getCurrentScope, onScopeDispose, 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' + +/** + * 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 + /** 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. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.openDelay.value) + * ``` + */ + openDelay: Readonly> + /** + * Reactive close delay in ms. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.closeDelay.value) + * ``` + */ + closeDelay: Readonly> + /** + * Reactive skip-window length in ms. + * + * @example + * ```ts + * const tooltip = useTooltip() + * console.log(tooltip.skipDelay.value) + * ``` + */ + skipDelay: Readonly> + /** + * 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. + * + * @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. + * + * @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. + * + * @example + * ```ts + * const tooltip = useTooltip() + * const delay = tooltip.shouldSkipOpenDelay() ? 0 : tooltip.openDelay.value + * ``` + */ + shouldSkipOpenDelay: () => boolean + /** + * 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 type TooltipPluginOptions = TooltipContextOptions + +// 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(() => 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(null) + + const isAnyOpen = toRef(() => registry.size > 0) + + function shouldSkipOpenDelay (): boolean { + if (isAnyOpen.value) return true + if (isNull(lastClosedAt.value)) return false + const elapsed = performance.now() - lastClosedAt.value + return elapsed >= 0 && elapsed < skipDelay.value + } + + function register (input?: Partial): RegistryTicket { + return registry.register(input) + } + + function unregister (id: ID): void { + if (!registry.has(id)) return + registry.unregister(id) + lastClosedAt.value = performance.now() + } + + if (getCurrentScope()) { + onScopeDispose(() => registry.dispose()) + } + + return { + openDelay, + closeDelay, + skipDelay, + disabled, + isAnyOpen, + shouldSkipOpenDelay, + register, + unregister, + } +} + +/** + * 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(), + }, + ) diff --git a/packages/0/src/maturity.json b/packages/0/src/maturity.json index 48508dfd7..7f7d4fb3b 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", @@ -489,7 +494,8 @@ "category": "semantic" }, "Tooltip": { - "level": "draft", + "level": "preview", + "since": null, "category": "disclosure" }, "Alert": {