Skip to content

Commit dd1f0df

Browse files
authored
feat(Tooltip): headless tooltip component with region coordination (#226)
1 parent e0259db commit dd1f0df

25 files changed

Lines changed: 1826 additions & 62 deletions

File tree

.claude/rules/components.md

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -279,18 +279,62 @@ When a composable uses `useProxyModel`, its underlying registry/model must have
279279

280280
Never `export *` from `.vue` files — breaks Volar slot type inference. [intent:184, intent:338]
281281

282+
A barrel is one of two shapes — **compound** or **single** — chosen by whether the component exposes dotted sub-components. Audited 100% consistent across all 39 component barrels (the one historical violator, Tooltip's `Object.assign`, was normalized to this canon).
283+
284+
### Compound (Root + sub-components)
285+
282286
```ts
283-
// Named exports for tree-shaking
284-
export type { ComponentRootProps, ComponentRootSlotProps } from './ComponentRoot.vue'
287+
// 1. Named default re-export per sub-component; co-locate the Root's context fns
288+
// with the Root default. Never `export *` from a .vue.
289+
export { default as ComponentItem } from './ComponentItem.vue'
290+
export { provideComponentRoot, useComponentRoot } from './ComponentRoot.vue'
285291
export { default as ComponentRoot } from './ComponentRoot.vue'
286-
export { useComponentRoot, provideComponentRoot } from './ComponentRoot.vue'
287292

288-
// Object compound export for dot notation
289-
import ComponentRoot from './ComponentRoot.vue'
290-
import ComponentItem from './ComponentItem.vue'
291-
export const Component = { Root: ComponentRoot, Item: ComponentItem }
293+
// 2. All `export type` re-exports grouped in one block after the named exports.
294+
export type { ComponentItemProps, ComponentItemSlotProps } from './ComponentItem.vue'
295+
export type { ComponentRootContext, ComponentRootProps, ComponentRootSlotProps } from './ComponentRoot.vue'
296+
297+
// Context
298+
import Item from './ComponentItem.vue'
299+
import Root from './ComponentRoot.vue'
300+
301+
/**
302+
* Component compound.
303+
*
304+
* @see
305+
* @example
306+
*/
307+
export const Component = {
308+
/** Single instance root. @example */ Root,
309+
/** A repeated item. @example */ Item,
310+
}
292311
```
293312

313+
- The compound is a **plain object literal** built from short-aliased default imports. **Never `Object.assign`**, and never re-bind the compound name to a `.vue` default — the namespace object is *not* a renderable component. `<Component>` (bare) is invalid; only `<Component.Root>` renders.
314+
- Every member of the literal carries a one-line `/** */` with `@example` (it surfaces in `<DocsApi />`). Member order is anatomy/usage order — the Root (or the outer region provider) first — not alphabetical.
315+
- `// Context` is the (de-facto, slightly misnamed) header over the short-aliased sub-component imports — keep it for family consistency.
316+
- `perfectionist/sort-imports` settles ordering *within* each export/import block; run `pnpm lint:fix`, don't hand-author order.
317+
318+
### Single (one renderable component — providers like Theme/Locale/Scrim, primitives like Atom/Form/Portal)
319+
320+
```ts
321+
export type { ComponentProps, ComponentSlotProps } from './Component.vue'
322+
323+
/**
324+
* @see
325+
* @example
326+
*/
327+
export { default as Component } from './Component.vue'
328+
```
329+
330+
Type re-export at the top, JSDoc, single default at the bottom. No compound object, no `// Context` imports, no provide/use.
331+
332+
### Region/scope providers belong to the compound, never the namespace
333+
334+
A wrapper that supplies shared defaults or context to a compound family's Roots (delay defaults, a selection group, a notification queue) is a **dotted member** of the compound — living in its own `Component<Member>.vue` file and named-exported like any other sub-component. It is **never** the bare renderable `<Component>`, and the compound is never made renderable via `Object.assign` to host it. Precedent: `ExpansionPanel.Group`, `Toggle.Group`, `Switch.Group`, `Radio.Group`, `Checkbox.Group`, `Button.Group`, `Snackbar.Queue`.
335+
336+
**Naming.** Name the member with the **domain noun** for what it coordinates — `.Group` (a selection group), `.Queue` (a notification queue). When the member is a *pure* context/defaults provider with no domain verb of its own, `.Provider` is acceptable, mirroring the upstream React-ecosystem `.Provider` wrappers. Prefer a domain noun where one fits; reach for `.Provider` only for a defaults-only wrapper. No shipped v0 compound uses `.Provider` today — app-wide defaults are supplied by a framework plugin (e.g. `createTooltipPlugin`) rather than a Provider component, which is the v0 analog of the React-ecosystem Provider.
337+
294338
[intent:185]
295339

296340
## Template Pattern (100% enforced)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import { Tooltip } from '@vuetify/v0'
3+
4+
const { label } = defineProps<{ label: string }>()
5+
</script>
6+
7+
<template>
8+
<Tooltip.Root>
9+
<Tooltip.Activator
10+
class="h-8 w-8 inline-flex items-center justify-center rounded-md border border-divider bg-surface text-on-surface text-sm hover:bg-surface-tint"
11+
>
12+
<slot />
13+
</Tooltip.Activator>
14+
15+
<Tooltip.Content
16+
class="px-2 py-1 rounded text-xs bg-on-surface text-surface shadow-md"
17+
:style="{ margin: '6px 0' }"
18+
>
19+
{{ label }}
20+
</Tooltip.Content>
21+
</Tooltip.Root>
22+
</template>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import { Tooltip } from '@vuetify/v0'
3+
</script>
4+
5+
<template>
6+
<div class="flex justify-center p-12">
7+
<Tooltip.Root :close-delay="200" :open-delay="500">
8+
<Tooltip.Activator
9+
class="px-3 py-1 rounded border border-divider bg-surface text-on-surface hover:bg-surface-tint"
10+
>
11+
Hover me
12+
</Tooltip.Activator>
13+
14+
<Tooltip.Content
15+
class="px-2 py-1 rounded text-xs bg-on-surface text-surface shadow-md"
16+
:style="{ margin: '6px 0' }"
17+
>
18+
Helpful description
19+
</Tooltip.Content>
20+
</Tooltip.Root>
21+
</div>
22+
</template>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
import { Button, Tooltip, useTimer } from '@vuetify/v0'
3+
import { shallowRef } from 'vue'
4+
import TooltipButton from './TooltipButton.vue'
5+
6+
const copied = shallowRef(false)
7+
8+
const reset = useTimer(() => {
9+
copied.value = false
10+
}, { duration: 3000 })
11+
12+
function onCopy () {
13+
copied.value = true
14+
reset.start()
15+
}
16+
</script>
17+
18+
<template>
19+
<div class="flex flex-col items-center gap-3 p-12">
20+
<p class="max-w-sm text-center text-sm text-on-surface-variant">
21+
Hover a button and wait, then glide to its neighbors — they open instantly. One tooltip plugin keeps the whole toolbar's region warm, so only the first hover waits the open delay.
22+
</p>
23+
24+
<div class="flex items-center gap-1 rounded-lg border border-divider bg-surface p-1">
25+
<TooltipButton label="Bold">
26+
<span class="font-bold">B</span>
27+
</TooltipButton>
28+
29+
<TooltipButton label="Italic">
30+
<span class="italic">I</span>
31+
</TooltipButton>
32+
33+
<TooltipButton label="Underline">
34+
<span class="underline">U</span>
35+
</TooltipButton>
36+
37+
<div class="mx-1 h-5 w-px bg-divider" />
38+
39+
<Tooltip.Root :close-delay="300" interactive>
40+
<Tooltip.Activator
41+
class="h-8 inline-flex items-center rounded-md border border-divider bg-surface px-2 text-sm text-on-surface hover:bg-surface-tint"
42+
>
43+
Link
44+
</Tooltip.Activator>
45+
46+
<Tooltip.Content
47+
class="rounded-lg bg-on-surface text-surface shadow-lg"
48+
:style="{ margin: '6px 0' }"
49+
>
50+
<div class="flex items-center gap-2 p-2">
51+
<code class="text-xs">vuetifyjs.com/0</code>
52+
53+
<Button.Root
54+
class="rounded bg-surface/15 px-2 py-1 text-xs hover:bg-surface/25 data-[copied]:bg-success data-[copied]:text-on-success"
55+
:data-copied="copied || undefined"
56+
@click="onCopy"
57+
>
58+
{{ copied ? 'Copied!' : 'Copy' }}
59+
</Button.Root>
60+
</div>
61+
</Tooltip.Content>
62+
</Tooltip.Root>
63+
</div>
64+
</div>
65+
</template>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script setup lang="ts">
2+
import { Tooltip, useTooltip } from '@vuetify/v0'
3+
4+
const region = useTooltip()
5+
</script>
6+
7+
<template>
8+
<div class="flex flex-col gap-4 items-center">
9+
<div class="flex gap-2 text-xs">
10+
<div
11+
class="px-2 py-1 rounded"
12+
:class="region.isAnyOpen.value
13+
? 'bg-success text-on-success'
14+
: 'bg-surface-variant text-on-surface-variant'"
15+
>
16+
isAnyOpen: {{ region.isAnyOpen.value }}
17+
</div>
18+
19+
<div class="px-2 py-1 rounded bg-surface-variant text-on-surface-variant">
20+
openDelay: {{ region.openDelay.value }}ms
21+
</div>
22+
23+
<div class="px-2 py-1 rounded bg-surface-variant text-on-surface-variant">
24+
skipDelay: {{ region.skipDelay.value }}ms
25+
</div>
26+
</div>
27+
28+
<div class="flex gap-3">
29+
<Tooltip.Root v-for="i in 4" :key="i">
30+
<Tooltip.Activator
31+
class="px-3 py-1 rounded border border-divider bg-surface text-on-surface hover:bg-surface-tint"
32+
>
33+
Item {{ i }}
34+
</Tooltip.Activator>
35+
36+
<Tooltip.Content
37+
class="px-2 py-1 rounded text-xs bg-on-surface text-surface shadow-md"
38+
>
39+
Description for item {{ i }}
40+
</Tooltip.Content>
41+
</Tooltip.Root>
42+
</div>
43+
44+
<p class="text-xs text-on-surface-variant max-w-md text-center">
45+
Hover the first item — wait {{ region.openDelay.value }}ms for the tooltip.
46+
Move to a neighbor while one is still open — the next appears instantly.
47+
Leave all four. After {{ region.skipDelay.value }}ms of idle, the next hover
48+
pays the full open delay again.
49+
</p>
50+
</div>
51+
</template>
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
title: Tooltip - Headless Description Tooltip with Hover and Focus Triggers
3+
meta:
4+
- name: description
5+
content: Headless tooltip component with hover and focus activation, region-scoped delay coordination, configurable interactive-content mode, and WAI-ARIA compliant aria-describedby semantics.
6+
- name: keywords
7+
content: tooltip, hover, focus, popover, ARIA, accessibility, v-bind, slots, Vue 3, headless
8+
features:
9+
category: Component
10+
label: 'C: Tooltip'
11+
github: /components/Tooltip/
12+
renderless: true
13+
level: 2
14+
related:
15+
- /composables/plugins/use-tooltip
16+
- /composables/system/use-popover
17+
- /components/disclosure/popover
18+
---
19+
20+
# Tooltip
21+
22+
Headless description tooltip with hover and focus triggers, configurable open/close delays, region-scoped skip-window coordination, and optional interactive-content mode.
23+
24+
<DocsPageFeatures :frontmatter />
25+
26+
## Usage
27+
28+
::: example
29+
/components/tooltip/basic
30+
:::
31+
32+
## Anatomy
33+
34+
```vue Anatomy playground
35+
<script setup lang="ts">
36+
import { Tooltip } from '@vuetify/v0'
37+
</script>
38+
39+
<template>
40+
<Tooltip.Root>
41+
<Tooltip.Activator />
42+
<Tooltip.Content />
43+
</Tooltip.Root>
44+
</template>
45+
```
46+
47+
## Architecture
48+
49+
```mermaid "Tooltip lifecycle"
50+
flowchart LR
51+
Closed -- "pointerenter (mouse) / focus (keyboard)" --> OpenScheduled
52+
OpenScheduled -- "openDelay elapses" --> Open
53+
OpenScheduled -- "skip window active" --> Open
54+
Open -- "pointerleave / blur / click / Escape" --> CloseScheduled
55+
CloseScheduled -- "closeDelay elapses" --> Closed
56+
CloseScheduled -- "pointerenter (interactive content)" --> Open
57+
```
58+
59+
## Examples
60+
61+
::: example
62+
/components/tooltip/TooltipButton.vue 1
63+
/components/tooltip/toolbar.vue 2
64+
65+
### Coordinated toolbar
66+
67+
A single tooltip hides one of v0's better tricks: the `createTooltipPlugin` registry coordinates *every* tooltip in the app from one place. Hover a toolbar button and wait out the open delay, then slide to a neighbor — it opens instantly, because the shared region is already warm. Without the plugin each tooltip would re-wait its own delay on every move, so the toolbar would feel sluggish. That shared skip-window is exactly why open and close timing lives in one plugin instead of per-tooltip state.
68+
69+
The `Link` control adds `interactive`: its content stays open while you move the pointer in to copy the URL, and Copy flips to a `bg-success` / `text-on-success` confirmation that stays legible in both light and dark themes. Reach for `interactive` only when the content genuinely needs pointer access — the strict WAI-ARIA APG tooltip pattern forbids focusable content, so a richer surface may belong in a future `HoverCard` instead.
70+
71+
| File | Role |
72+
|------|------|
73+
| `TooltipButton.vue` | Reusable wrapper pairing a `Button` activator with its tooltip |
74+
| `toolbar.vue` | Formatting toolbar wiring several coordinated tooltips plus one interactive `Link` |
75+
76+
:::
77+
78+
## Accessibility
79+
80+
| Concern | Behavior |
81+
|---------|----------|
82+
| Role | Content renders `role="tooltip"` |
83+
| Linkage | Activator always carries `aria-describedby={contentId}` so screen readers announce the description on focus |
84+
| Keyboard | Focus opens instantly (no delay), Escape closes; Enter / Space activate the underlying control, which closes via click |
85+
| Touch | Tooltips are not shown on touch interactions per the WAI-ARIA APG |
86+
| Hoverable content | Off by default; opt-in with `interactive` on `<Tooltip.Root>` |
87+
88+
## FAQ
89+
90+
::: faq
91+
92+
??? Why don't tooltips show on touch?
93+
94+
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.
95+
96+
??? How do I set default open and close delays?
97+
98+
Two layers, and the narrower one wins. App-wide: install the plugin — `app.use(createTooltipPlugin({ openDelay: 500, closeDelay: 150 }))`. One tooltip: set props on its Root — `<Tooltip.Root :open-delay="0">`. Warmup coordination stays shared across every tooltip through the plugin registry. With no plugin installed it still works, falling back to the documented defaults.
99+
100+
??? Why doesn't Tooltip.Activator open when I focus it via mouse click?
101+
102+
The activator gates focus-driven opens on `:focus-visible`. A pointer click that incidentally moves focus into the activator does not match `:focus-visible`, so it doesn't open the tooltip. Keyboard-driven focus (Tab) sets `:focus-visible` and opens the tooltip instantly.
103+
104+
??? How do I render a non-button activator?
105+
106+
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).
107+
108+
:::
109+
110+
<DocsApi />

apps/docs/src/pages/components/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,6 @@ Components for showing/hiding content.
9797
| [ExpansionPanel](/components/disclosure/expansion-panel) | Accordion-style collapsible panels |
9898
| [Popover](/components/disclosure/popover) | CSS anchor-positioned popup content |
9999
| [Tabs](/components/disclosure/tabs) | Tab panel navigation with keyboard support and lazy content rendering |
100+
| [Tooltip](/components/disclosure/tooltip) | Description tooltip with hover/focus triggers and shared delay coordination |
100101
| [Treeview](/components/disclosure/treeview) | Hierarchical tree with nested selection and expand/collapse |
101102

apps/docs/src/pages/composables/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ Application-level features installable via Vue plugins.
304304
| [useStack](/composables/plugins/use-stack) | Overlay z-index stacking with automatic calculation and scrim integration |
305305
| [useStorage](/composables/plugins/use-storage) | Reactive browser storage interface |
306306
| [useTheme](/composables/plugins/use-theme) | Theme management with CSS custom properties |
307+
| [useTooltip](/composables/plugins/use-tooltip) | Region-scoped tooltip delay coordination plugin |
307308

308309
## Data
309310

0 commit comments

Comments
 (0)