feat(Tooltip): headless tooltip component with region coordination#226
Open
johnleider wants to merge 17 commits into
Open
feat(Tooltip): headless tooltip component with region coordination#226johnleider wants to merge 17 commits into
johnleider wants to merge 17 commits into
Conversation
|
commit: |
97596ba to
01396c4
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<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.useTooltipplugin trinity (createTooltipContext,createTooltipPlugin,useTooltip) for region-scoped delay coordination via a small open-tooltip registry.usePopover.showDelay/hideDelay→openDelay/closeDelayand switches its internals to composeuseDelay. Preview API; non-breaking elsewhere because no v0 component currently passes those options.API
app.use(createTooltipPlugin({ openDelay: 500 }))for app-level defaults.Accessibility
role="tooltip"on contentaria-describedbyon activator while openNotes
preview,since: nullusePopover.attach(line 138 now useselement?.showPopover?.(), matching the watcher at line 148) — surfaced by the integration tests'defaultOpen: truepath.