Skip to content

feat(Tooltip): headless tooltip component with region coordination#226

Open
johnleider wants to merge 17 commits into
masterfrom
feat/tooltip
Open

feat(Tooltip): headless tooltip component with region coordination#226
johnleider wants to merge 17 commits into
masterfrom
feat/tooltip

Conversation

@johnleider
Copy link
Copy Markdown
Member

Summary

  • Adds <Tooltip> / <Tooltip.Root> / <Tooltip.Activator> / <Tooltip.Content> — a headless compound tooltip component with hover and focus triggers, configurable open/close delays, and optional interactive-content mode.
  • Adds useTooltip plugin trinity (createTooltipContext, createTooltipPlugin, useTooltip) for region-scoped delay coordination via a small open-tooltip registry.
  • Renames usePopover.showDelay / hideDelayopenDelay / closeDelay and switches its internals to compose useDelay. Preview API; non-breaking elsewhere because no v0 component currently passes those options.

API

<Tooltip :openDelay="300">
  <Tooltip.Root>
    <Tooltip.Activator>Hover me</Tooltip.Activator>
    <Tooltip.Content>Helpful description</Tooltip.Content>
  </Tooltip.Root>
</Tooltip>

app.use(createTooltipPlugin({ openDelay: 500 })) for app-level defaults.

Accessibility

  • role="tooltip" on content
  • aria-describedby on activator while open
  • Focus opens instantly (no delay) for keyboard users
  • Escape closes
  • Touch interactions are suppressed per WAI-ARIA APG / React Aria policy

Notes

  • Maturity: preview, since: null
  • Bundles a defensive consistency fix in usePopover.attach (line 138 now uses element?.showPopover?.(), matching the watcher at line 148) — surfaced by the integration tests' defaultOpen: true path.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 1, 2026

Open in StackBlitz

commit: 949bfff

@johnleider johnleider force-pushed the feat/tooltip branch 2 times, most recently from 97596ba to 01396c4 Compare May 4, 2026 01:42
johnleider added 16 commits May 9, 2026 20:53
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.
- 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
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.
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.
Adds:
- <Tooltip> — optional scope wrapper for region-level delay overrides,
  mirrors <Theme> / <Locale>
- <Tooltip.Root> — composes usePopover + useDelay, registers with the
  useTooltip region for skip-window coordination
- <Tooltip.Activator> — pointer + focus + escape wiring, suppresses
  touch opens, instant on keyboard focus
- <Tooltip.Content> — 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.
- 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
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<T> 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<RegistryTicket>, 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)
The Tooltip docs page mounts <TooltipRoot> 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.
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.
… tooltips

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.
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<RegistryTicket> (output shape) to
  Partial<RegistryTicketInput> (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.
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant