@asnewyla/unstyled-tooltip 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @asnewyla/unstyled-tooltip
2
+
3
+ Unstyled, accessible tooltip primitive. Compound components, hover/focus open
4
+ with a delay, Escape to dismiss — no visual styling, the base layer under
5
+ `@asnewyla/tooltip`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @asnewyla/unstyled-tooltip
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```tsx
16
+ import {
17
+ Tooltip,
18
+ TooltipTrigger,
19
+ TooltipContent,
20
+ } from '@asnewyla/unstyled-tooltip';
21
+
22
+ <Tooltip>
23
+ <TooltipTrigger>
24
+ <button type="button">Save</button>
25
+ </TooltipTrigger>
26
+ <TooltipContent>Saves your work automatically</TooltipContent>
27
+ </Tooltip>
28
+
29
+ // Controlled
30
+ <Tooltip open={open} onOpenChange={setOpen}>
31
+ ...
32
+ </Tooltip>
33
+ ```
34
+
35
+ ### Components
36
+
37
+ | Component | Renders | Notes |
38
+ |---|---|---|
39
+ | `Tooltip` | `<span>` wrapper | Owns open state (`open`/`defaultOpen`/`onOpenChange`), `delay?` (hover open delay, ms, default `700`). The wrapper is `position: relative; display: inline-block` so `TooltipContent` can place itself with plain CSS |
40
+ | `TooltipTrigger` | its single child, cloned | Injects `aria-describedby` (while open) and pointer/focus/blur handlers onto whatever element you pass — a `<button>`, a link, an input. The child must forward `ref` and props |
41
+ | `TooltipContent` | `<span role="tooltip">` | `side?` → `data-side` (`top`/`right`/`bottom`/`left`, default `top`). Rendered only while open |
42
+
43
+ ### Behavior
44
+
45
+ - Opens on pointer enter after `delay` ms, or immediately on keyboard focus.
46
+ - Closes on pointer leave, blur, `Escape`, or pressing the trigger.
47
+ - `Escape` closing is the one hard WAI-ARIA requirement and works regardless of how the tooltip was opened.
48
+ - `aria-describedby` on the trigger points at the content's id only while the tooltip is open.
49
+
50
+ ### Not yet supported
51
+
52
+ - **Collision-aware positioning.** `side` is honored as-is; a tooltip near a viewport edge is not flipped or shifted.
53
+ - **Portal rendering.** The content renders inline, so an ancestor with `overflow: hidden` can clip it.
54
+ - **Hoverable content.** Moving the pointer onto the tooltip closes it — it's for display, not interaction.
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,35 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ComponentPropsWithoutRef, ReactElement } from 'react';
3
+
4
+ type TooltipProps = {
5
+ open?: boolean;
6
+ defaultOpen?: boolean;
7
+ onOpenChange?: (open: boolean) => void;
8
+ /** Hover open delay in ms. Keyboard focus always opens with no delay. */
9
+ delay?: number;
10
+ children: ReactNode;
11
+ } & Omit<ComponentPropsWithoutRef<'span'>, 'onChange' | 'children'>;
12
+ declare const Tooltip: react.ForwardRefExoticComponent<{
13
+ open?: boolean;
14
+ defaultOpen?: boolean;
15
+ onOpenChange?: (open: boolean) => void;
16
+ /** Hover open delay in ms. Keyboard focus always opens with no delay. */
17
+ delay?: number;
18
+ children: ReactNode;
19
+ } & Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "children" | "onChange"> & react.RefAttributes<HTMLSpanElement>>;
20
+
21
+ type TooltipSide = 'top' | 'right' | 'bottom' | 'left';
22
+ type TooltipContentProps = {
23
+ side?: TooltipSide;
24
+ } & Omit<ComponentPropsWithoutRef<'span'>, 'role' | 'id'>;
25
+ declare const TooltipContent: react.ForwardRefExoticComponent<{
26
+ side?: TooltipSide;
27
+ } & Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "id" | "role"> & react.RefAttributes<HTMLSpanElement>>;
28
+
29
+ type TooltipTriggerProps = {
30
+ /** A single focusable element. It is cloned, not wrapped. */
31
+ children: ReactNode;
32
+ };
33
+ declare function TooltipTrigger({ children, }: TooltipTriggerProps): ReactElement;
34
+
35
+ export { Tooltip, TooltipContent, type TooltipContentProps, type TooltipProps, type TooltipSide, TooltipTrigger, type TooltipTriggerProps };
@@ -0,0 +1,35 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ComponentPropsWithoutRef, ReactElement } from 'react';
3
+
4
+ type TooltipProps = {
5
+ open?: boolean;
6
+ defaultOpen?: boolean;
7
+ onOpenChange?: (open: boolean) => void;
8
+ /** Hover open delay in ms. Keyboard focus always opens with no delay. */
9
+ delay?: number;
10
+ children: ReactNode;
11
+ } & Omit<ComponentPropsWithoutRef<'span'>, 'onChange' | 'children'>;
12
+ declare const Tooltip: react.ForwardRefExoticComponent<{
13
+ open?: boolean;
14
+ defaultOpen?: boolean;
15
+ onOpenChange?: (open: boolean) => void;
16
+ /** Hover open delay in ms. Keyboard focus always opens with no delay. */
17
+ delay?: number;
18
+ children: ReactNode;
19
+ } & Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "children" | "onChange"> & react.RefAttributes<HTMLSpanElement>>;
20
+
21
+ type TooltipSide = 'top' | 'right' | 'bottom' | 'left';
22
+ type TooltipContentProps = {
23
+ side?: TooltipSide;
24
+ } & Omit<ComponentPropsWithoutRef<'span'>, 'role' | 'id'>;
25
+ declare const TooltipContent: react.ForwardRefExoticComponent<{
26
+ side?: TooltipSide;
27
+ } & Omit<Omit<react.DetailedHTMLProps<react.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref">, "id" | "role"> & react.RefAttributes<HTMLSpanElement>>;
28
+
29
+ type TooltipTriggerProps = {
30
+ /** A single focusable element. It is cloned, not wrapped. */
31
+ children: ReactNode;
32
+ };
33
+ declare function TooltipTrigger({ children, }: TooltipTriggerProps): ReactElement;
34
+
35
+ export { Tooltip, TooltipContent, type TooltipContentProps, type TooltipProps, type TooltipSide, TooltipTrigger, type TooltipTriggerProps };
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/components/Tooltip.tsx
7
+ var TooltipContext = react.createContext(null);
8
+ function useTooltipContext() {
9
+ const context = react.useContext(TooltipContext);
10
+ if (!context) {
11
+ throw new Error(
12
+ "Tooltip compound components (TooltipTrigger, TooltipContent) must be rendered inside <Tooltip>"
13
+ );
14
+ }
15
+ return context;
16
+ }
17
+ function useTooltipOpen({
18
+ open,
19
+ defaultOpen,
20
+ onOpenChange
21
+ }) {
22
+ const isControlled = open !== void 0;
23
+ const [internalOpen, setInternalOpen] = react.useState(defaultOpen ?? false);
24
+ const actualOpen = isControlled ? open : internalOpen;
25
+ const setOpen = react.useCallback(
26
+ (next) => {
27
+ if (!isControlled) {
28
+ setInternalOpen(next);
29
+ }
30
+ onOpenChange?.(next);
31
+ },
32
+ [isControlled, onOpenChange]
33
+ );
34
+ return { open: actualOpen, setOpen };
35
+ }
36
+ function TooltipInner({
37
+ open,
38
+ defaultOpen,
39
+ onOpenChange,
40
+ delay = 700,
41
+ children,
42
+ style,
43
+ ...rest
44
+ }, ref) {
45
+ const contentId = react.useId();
46
+ const { open: isOpen, setOpen } = useTooltipOpen({
47
+ open,
48
+ defaultOpen,
49
+ onOpenChange
50
+ });
51
+ const openTimer = react.useRef(null);
52
+ const clearOpenTimer = react.useCallback(() => {
53
+ if (openTimer.current !== null) {
54
+ clearTimeout(openTimer.current);
55
+ openTimer.current = null;
56
+ }
57
+ }, []);
58
+ const close = react.useCallback(() => {
59
+ clearOpenTimer();
60
+ setOpen(false);
61
+ }, [clearOpenTimer, setOpen]);
62
+ const openNow = react.useCallback(() => {
63
+ clearOpenTimer();
64
+ setOpen(true);
65
+ }, [clearOpenTimer, setOpen]);
66
+ const openWithDelay = react.useCallback(() => {
67
+ clearOpenTimer();
68
+ if (delay <= 0) {
69
+ setOpen(true);
70
+ return;
71
+ }
72
+ openTimer.current = setTimeout(() => {
73
+ openTimer.current = null;
74
+ setOpen(true);
75
+ }, delay);
76
+ }, [clearOpenTimer, delay, setOpen]);
77
+ react.useEffect(() => clearOpenTimer, [clearOpenTimer]);
78
+ react.useEffect(() => {
79
+ if (!isOpen) return;
80
+ const onKeyDown = (event) => {
81
+ if (event.key === "Escape") {
82
+ close();
83
+ }
84
+ };
85
+ document.addEventListener("keydown", onKeyDown);
86
+ return () => document.removeEventListener("keydown", onKeyDown);
87
+ }, [isOpen, close]);
88
+ const contextValue = react.useMemo(
89
+ () => ({ open: isOpen, contentId, openNow, openWithDelay, close }),
90
+ [isOpen, contentId, openNow, openWithDelay, close]
91
+ );
92
+ const wrapperStyle = {
93
+ position: "relative",
94
+ display: "inline-block",
95
+ ...style
96
+ };
97
+ return /* @__PURE__ */ jsxRuntime.jsx(TooltipContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx("span", { ...rest, ref, style: wrapperStyle, children }) });
98
+ }
99
+ var Tooltip = react.forwardRef(TooltipInner);
100
+ function TooltipContentInner({ side = "top", ...rest }, ref) {
101
+ const { open, contentId } = useTooltipContext();
102
+ if (!open) {
103
+ return null;
104
+ }
105
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { ...rest, ref, role: "tooltip", id: contentId, "data-side": side });
106
+ }
107
+ var TooltipContent = react.forwardRef(
108
+ TooltipContentInner
109
+ );
110
+
111
+ // src/components/composeEventHandlers.ts
112
+ function composeEventHandlers(consumerHandler, ownHandler) {
113
+ return (event) => {
114
+ consumerHandler?.(event);
115
+ ownHandler(event);
116
+ };
117
+ }
118
+
119
+ // src/components/TooltipTrigger.tsx
120
+ function TooltipTrigger({
121
+ children
122
+ }) {
123
+ const { open, contentId, openNow, openWithDelay, close } = useTooltipContext();
124
+ const child = react.Children.only(children);
125
+ const childProps = child.props;
126
+ const describedBy = open ? [childProps["aria-describedby"], contentId].filter(Boolean).join(" ") : childProps["aria-describedby"];
127
+ return react.cloneElement(child, {
128
+ "aria-describedby": describedBy,
129
+ onPointerEnter: composeEventHandlers(
130
+ childProps.onPointerEnter,
131
+ openWithDelay
132
+ ),
133
+ onPointerLeave: composeEventHandlers(childProps.onPointerLeave, close),
134
+ onPointerDown: composeEventHandlers(childProps.onPointerDown, close),
135
+ onFocus: composeEventHandlers(childProps.onFocus, openNow),
136
+ onBlur: composeEventHandlers(childProps.onBlur, close)
137
+ });
138
+ }
139
+
140
+ exports.Tooltip = Tooltip;
141
+ exports.TooltipContent = TooltipContent;
142
+ exports.TooltipTrigger = TooltipTrigger;
143
+ //# sourceMappingURL=index.js.map
144
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/TooltipContext.tsx","../src/components/Tooltip.tsx","../src/components/TooltipContent.tsx","../src/components/composeEventHandlers.ts","../src/components/TooltipTrigger.tsx"],"names":["createContext","useContext","useState","useCallback","useId","useRef","useEffect","useMemo","jsx","forwardRef","Children","cloneElement"],"mappings":"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","file":"index.js","sourcesContent":["import { createContext, useCallback, useContext, useState } from 'react';\n\nexport type TooltipContextValue = {\n open: boolean;\n contentId: string;\n /** Open now, no delay — used for keyboard focus. */\n openNow: () => void;\n /** Open after the configured hover delay — used for pointer enter. */\n openWithDelay: () => void;\n /** Close now and cancel any pending open. */\n close: () => void;\n};\n\nexport const TooltipContext = createContext<TooltipContextValue | null>(null);\n\nexport function useTooltipContext(): TooltipContextValue {\n const context = useContext(TooltipContext);\n if (!context) {\n throw new Error(\n 'Tooltip compound components (TooltipTrigger, TooltipContent) must be rendered inside <Tooltip>',\n );\n }\n return context;\n}\n\nexport function useTooltipOpen({\n open,\n defaultOpen,\n onOpenChange,\n}: {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n}): { open: boolean; setOpen: (next: boolean) => void } {\n const isControlled = open !== undefined;\n const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);\n const actualOpen = isControlled ? open : internalOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) {\n setInternalOpen(next);\n }\n onOpenChange?.(next);\n },\n [isControlled, onOpenChange],\n );\n\n return { open: actualOpen, setOpen };\n}\n","import {\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type ForwardedRef,\n forwardRef,\n type ReactNode,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n} from 'react';\nimport { TooltipContext, useTooltipOpen } from './TooltipContext';\n\nexport type TooltipProps = {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Hover open delay in ms. Keyboard focus always opens with no delay. */\n delay?: number;\n children: ReactNode;\n} & Omit<ComponentPropsWithoutRef<'span'>, 'onChange' | 'children'>;\n\nfunction TooltipInner(\n {\n open,\n defaultOpen,\n onOpenChange,\n delay = 700,\n children,\n style,\n ...rest\n }: TooltipProps,\n ref: ForwardedRef<HTMLSpanElement>,\n) {\n const contentId = useId();\n const { open: isOpen, setOpen } = useTooltipOpen({\n open,\n defaultOpen,\n onOpenChange,\n });\n\n const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const clearOpenTimer = useCallback(() => {\n if (openTimer.current !== null) {\n clearTimeout(openTimer.current);\n openTimer.current = null;\n }\n }, []);\n\n const close = useCallback(() => {\n clearOpenTimer();\n setOpen(false);\n }, [clearOpenTimer, setOpen]);\n\n const openNow = useCallback(() => {\n clearOpenTimer();\n setOpen(true);\n }, [clearOpenTimer, setOpen]);\n\n const openWithDelay = useCallback(() => {\n clearOpenTimer();\n if (delay <= 0) {\n setOpen(true);\n return;\n }\n openTimer.current = setTimeout(() => {\n openTimer.current = null;\n setOpen(true);\n }, delay);\n }, [clearOpenTimer, delay, setOpen]);\n\n useEffect(() => clearOpenTimer, [clearOpenTimer]);\n\n // Escape closes an open tooltip — WAI-ARIA APG's one hard requirement.\n useEffect(() => {\n if (!isOpen) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n close();\n }\n };\n document.addEventListener('keydown', onKeyDown);\n return () => document.removeEventListener('keydown', onKeyDown);\n }, [isOpen, close]);\n\n const contextValue = useMemo(\n () => ({ open: isOpen, contentId, openNow, openWithDelay, close }),\n [isOpen, contentId, openNow, openWithDelay, close],\n );\n\n // A shrink-wrapped positioned box so TooltipContent can place itself with\n // plain CSS (position: absolute + data-side) — no portal, no measurement.\n const wrapperStyle: CSSProperties = {\n position: 'relative',\n display: 'inline-block',\n ...style,\n };\n\n return (\n <TooltipContext.Provider value={contextValue}>\n <span {...rest} ref={ref} style={wrapperStyle}>\n {children}\n </span>\n </TooltipContext.Provider>\n );\n}\n\nexport const Tooltip = forwardRef<HTMLSpanElement, TooltipProps>(TooltipInner);\n","import {\n type ComponentPropsWithoutRef,\n type ForwardedRef,\n forwardRef,\n} from 'react';\nimport { useTooltipContext } from './TooltipContext';\n\nexport type TooltipSide = 'top' | 'right' | 'bottom' | 'left';\n\nexport type TooltipContentProps = {\n side?: TooltipSide;\n} & Omit<ComponentPropsWithoutRef<'span'>, 'role' | 'id'>;\n\nfunction TooltipContentInner(\n { side = 'top', ...rest }: TooltipContentProps,\n ref: ForwardedRef<HTMLSpanElement>,\n) {\n const { open, contentId } = useTooltipContext();\n\n if (!open) {\n return null;\n }\n\n return (\n <span {...rest} ref={ref} role=\"tooltip\" id={contentId} data-side={side} />\n );\n}\n\nexport const TooltipContent = forwardRef<HTMLSpanElement, TooltipContentProps>(\n TooltipContentInner,\n);\n","/**\n * Wraps a consumer-supplied event handler so the component's own handler runs\n * after it. Both always run — a tooltip never wants the consumer to be able to\n * silently suppress its open/close behavior.\n */\nexport function composeEventHandlers<E>(\n consumerHandler: ((event: E) => void) | undefined,\n ownHandler: (event: E) => void,\n): (event: E) => void {\n return (event: E) => {\n consumerHandler?.(event);\n ownHandler(event);\n };\n}\n","import {\n Children,\n cloneElement,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { composeEventHandlers } from './composeEventHandlers';\nimport { useTooltipContext } from './TooltipContext';\n\nexport type TooltipTriggerProps = {\n /** A single focusable element. It is cloned, not wrapped. */\n children: ReactNode;\n};\n\ntype TriggerChildProps = {\n 'aria-describedby'?: string;\n onPointerEnter?: (event: PointerEvent) => void;\n onPointerLeave?: (event: PointerEvent) => void;\n onPointerDown?: (event: PointerEvent) => void;\n onFocus?: (event: React.FocusEvent) => void;\n onBlur?: (event: React.FocusEvent) => void;\n};\n\nexport function TooltipTrigger({\n children,\n}: TooltipTriggerProps): ReactElement {\n const { open, contentId, openNow, openWithDelay, close } =\n useTooltipContext();\n const child = Children.only(children) as ReactElement<TriggerChildProps>;\n const childProps = child.props;\n\n const describedBy = open\n ? [childProps['aria-describedby'], contentId].filter(Boolean).join(' ')\n : childProps['aria-describedby'];\n\n return cloneElement(child, {\n 'aria-describedby': describedBy,\n onPointerEnter: composeEventHandlers(\n childProps.onPointerEnter,\n openWithDelay,\n ),\n onPointerLeave: composeEventHandlers(childProps.onPointerLeave, close),\n onPointerDown: composeEventHandlers(childProps.onPointerDown, close),\n onFocus: composeEventHandlers(childProps.onFocus, openNow),\n onBlur: composeEventHandlers(childProps.onBlur, close),\n });\n}\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,140 @@
1
+ import { createContext, forwardRef, useId, useRef, useCallback, useEffect, useMemo, useState, useContext, Children, cloneElement } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
3
+
4
+ // src/components/Tooltip.tsx
5
+ var TooltipContext = createContext(null);
6
+ function useTooltipContext() {
7
+ const context = useContext(TooltipContext);
8
+ if (!context) {
9
+ throw new Error(
10
+ "Tooltip compound components (TooltipTrigger, TooltipContent) must be rendered inside <Tooltip>"
11
+ );
12
+ }
13
+ return context;
14
+ }
15
+ function useTooltipOpen({
16
+ open,
17
+ defaultOpen,
18
+ onOpenChange
19
+ }) {
20
+ const isControlled = open !== void 0;
21
+ const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
22
+ const actualOpen = isControlled ? open : internalOpen;
23
+ const setOpen = useCallback(
24
+ (next) => {
25
+ if (!isControlled) {
26
+ setInternalOpen(next);
27
+ }
28
+ onOpenChange?.(next);
29
+ },
30
+ [isControlled, onOpenChange]
31
+ );
32
+ return { open: actualOpen, setOpen };
33
+ }
34
+ function TooltipInner({
35
+ open,
36
+ defaultOpen,
37
+ onOpenChange,
38
+ delay = 700,
39
+ children,
40
+ style,
41
+ ...rest
42
+ }, ref) {
43
+ const contentId = useId();
44
+ const { open: isOpen, setOpen } = useTooltipOpen({
45
+ open,
46
+ defaultOpen,
47
+ onOpenChange
48
+ });
49
+ const openTimer = useRef(null);
50
+ const clearOpenTimer = useCallback(() => {
51
+ if (openTimer.current !== null) {
52
+ clearTimeout(openTimer.current);
53
+ openTimer.current = null;
54
+ }
55
+ }, []);
56
+ const close = useCallback(() => {
57
+ clearOpenTimer();
58
+ setOpen(false);
59
+ }, [clearOpenTimer, setOpen]);
60
+ const openNow = useCallback(() => {
61
+ clearOpenTimer();
62
+ setOpen(true);
63
+ }, [clearOpenTimer, setOpen]);
64
+ const openWithDelay = useCallback(() => {
65
+ clearOpenTimer();
66
+ if (delay <= 0) {
67
+ setOpen(true);
68
+ return;
69
+ }
70
+ openTimer.current = setTimeout(() => {
71
+ openTimer.current = null;
72
+ setOpen(true);
73
+ }, delay);
74
+ }, [clearOpenTimer, delay, setOpen]);
75
+ useEffect(() => clearOpenTimer, [clearOpenTimer]);
76
+ useEffect(() => {
77
+ if (!isOpen) return;
78
+ const onKeyDown = (event) => {
79
+ if (event.key === "Escape") {
80
+ close();
81
+ }
82
+ };
83
+ document.addEventListener("keydown", onKeyDown);
84
+ return () => document.removeEventListener("keydown", onKeyDown);
85
+ }, [isOpen, close]);
86
+ const contextValue = useMemo(
87
+ () => ({ open: isOpen, contentId, openNow, openWithDelay, close }),
88
+ [isOpen, contentId, openNow, openWithDelay, close]
89
+ );
90
+ const wrapperStyle = {
91
+ position: "relative",
92
+ display: "inline-block",
93
+ ...style
94
+ };
95
+ return /* @__PURE__ */ jsx(TooltipContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsx("span", { ...rest, ref, style: wrapperStyle, children }) });
96
+ }
97
+ var Tooltip = forwardRef(TooltipInner);
98
+ function TooltipContentInner({ side = "top", ...rest }, ref) {
99
+ const { open, contentId } = useTooltipContext();
100
+ if (!open) {
101
+ return null;
102
+ }
103
+ return /* @__PURE__ */ jsx("span", { ...rest, ref, role: "tooltip", id: contentId, "data-side": side });
104
+ }
105
+ var TooltipContent = forwardRef(
106
+ TooltipContentInner
107
+ );
108
+
109
+ // src/components/composeEventHandlers.ts
110
+ function composeEventHandlers(consumerHandler, ownHandler) {
111
+ return (event) => {
112
+ consumerHandler?.(event);
113
+ ownHandler(event);
114
+ };
115
+ }
116
+
117
+ // src/components/TooltipTrigger.tsx
118
+ function TooltipTrigger({
119
+ children
120
+ }) {
121
+ const { open, contentId, openNow, openWithDelay, close } = useTooltipContext();
122
+ const child = Children.only(children);
123
+ const childProps = child.props;
124
+ const describedBy = open ? [childProps["aria-describedby"], contentId].filter(Boolean).join(" ") : childProps["aria-describedby"];
125
+ return cloneElement(child, {
126
+ "aria-describedby": describedBy,
127
+ onPointerEnter: composeEventHandlers(
128
+ childProps.onPointerEnter,
129
+ openWithDelay
130
+ ),
131
+ onPointerLeave: composeEventHandlers(childProps.onPointerLeave, close),
132
+ onPointerDown: composeEventHandlers(childProps.onPointerDown, close),
133
+ onFocus: composeEventHandlers(childProps.onFocus, openNow),
134
+ onBlur: composeEventHandlers(childProps.onBlur, close)
135
+ });
136
+ }
137
+
138
+ export { Tooltip, TooltipContent, TooltipTrigger };
139
+ //# sourceMappingURL=index.mjs.map
140
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/TooltipContext.tsx","../src/components/Tooltip.tsx","../src/components/TooltipContent.tsx","../src/components/composeEventHandlers.ts","../src/components/TooltipTrigger.tsx"],"names":["useCallback","jsx","forwardRef"],"mappings":"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","file":"index.mjs","sourcesContent":["import { createContext, useCallback, useContext, useState } from 'react';\n\nexport type TooltipContextValue = {\n open: boolean;\n contentId: string;\n /** Open now, no delay — used for keyboard focus. */\n openNow: () => void;\n /** Open after the configured hover delay — used for pointer enter. */\n openWithDelay: () => void;\n /** Close now and cancel any pending open. */\n close: () => void;\n};\n\nexport const TooltipContext = createContext<TooltipContextValue | null>(null);\n\nexport function useTooltipContext(): TooltipContextValue {\n const context = useContext(TooltipContext);\n if (!context) {\n throw new Error(\n 'Tooltip compound components (TooltipTrigger, TooltipContent) must be rendered inside <Tooltip>',\n );\n }\n return context;\n}\n\nexport function useTooltipOpen({\n open,\n defaultOpen,\n onOpenChange,\n}: {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n}): { open: boolean; setOpen: (next: boolean) => void } {\n const isControlled = open !== undefined;\n const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);\n const actualOpen = isControlled ? open : internalOpen;\n\n const setOpen = useCallback(\n (next: boolean) => {\n if (!isControlled) {\n setInternalOpen(next);\n }\n onOpenChange?.(next);\n },\n [isControlled, onOpenChange],\n );\n\n return { open: actualOpen, setOpen };\n}\n","import {\n type ComponentPropsWithoutRef,\n type CSSProperties,\n type ForwardedRef,\n forwardRef,\n type ReactNode,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n} from 'react';\nimport { TooltipContext, useTooltipOpen } from './TooltipContext';\n\nexport type TooltipProps = {\n open?: boolean;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Hover open delay in ms. Keyboard focus always opens with no delay. */\n delay?: number;\n children: ReactNode;\n} & Omit<ComponentPropsWithoutRef<'span'>, 'onChange' | 'children'>;\n\nfunction TooltipInner(\n {\n open,\n defaultOpen,\n onOpenChange,\n delay = 700,\n children,\n style,\n ...rest\n }: TooltipProps,\n ref: ForwardedRef<HTMLSpanElement>,\n) {\n const contentId = useId();\n const { open: isOpen, setOpen } = useTooltipOpen({\n open,\n defaultOpen,\n onOpenChange,\n });\n\n const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const clearOpenTimer = useCallback(() => {\n if (openTimer.current !== null) {\n clearTimeout(openTimer.current);\n openTimer.current = null;\n }\n }, []);\n\n const close = useCallback(() => {\n clearOpenTimer();\n setOpen(false);\n }, [clearOpenTimer, setOpen]);\n\n const openNow = useCallback(() => {\n clearOpenTimer();\n setOpen(true);\n }, [clearOpenTimer, setOpen]);\n\n const openWithDelay = useCallback(() => {\n clearOpenTimer();\n if (delay <= 0) {\n setOpen(true);\n return;\n }\n openTimer.current = setTimeout(() => {\n openTimer.current = null;\n setOpen(true);\n }, delay);\n }, [clearOpenTimer, delay, setOpen]);\n\n useEffect(() => clearOpenTimer, [clearOpenTimer]);\n\n // Escape closes an open tooltip — WAI-ARIA APG's one hard requirement.\n useEffect(() => {\n if (!isOpen) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n close();\n }\n };\n document.addEventListener('keydown', onKeyDown);\n return () => document.removeEventListener('keydown', onKeyDown);\n }, [isOpen, close]);\n\n const contextValue = useMemo(\n () => ({ open: isOpen, contentId, openNow, openWithDelay, close }),\n [isOpen, contentId, openNow, openWithDelay, close],\n );\n\n // A shrink-wrapped positioned box so TooltipContent can place itself with\n // plain CSS (position: absolute + data-side) — no portal, no measurement.\n const wrapperStyle: CSSProperties = {\n position: 'relative',\n display: 'inline-block',\n ...style,\n };\n\n return (\n <TooltipContext.Provider value={contextValue}>\n <span {...rest} ref={ref} style={wrapperStyle}>\n {children}\n </span>\n </TooltipContext.Provider>\n );\n}\n\nexport const Tooltip = forwardRef<HTMLSpanElement, TooltipProps>(TooltipInner);\n","import {\n type ComponentPropsWithoutRef,\n type ForwardedRef,\n forwardRef,\n} from 'react';\nimport { useTooltipContext } from './TooltipContext';\n\nexport type TooltipSide = 'top' | 'right' | 'bottom' | 'left';\n\nexport type TooltipContentProps = {\n side?: TooltipSide;\n} & Omit<ComponentPropsWithoutRef<'span'>, 'role' | 'id'>;\n\nfunction TooltipContentInner(\n { side = 'top', ...rest }: TooltipContentProps,\n ref: ForwardedRef<HTMLSpanElement>,\n) {\n const { open, contentId } = useTooltipContext();\n\n if (!open) {\n return null;\n }\n\n return (\n <span {...rest} ref={ref} role=\"tooltip\" id={contentId} data-side={side} />\n );\n}\n\nexport const TooltipContent = forwardRef<HTMLSpanElement, TooltipContentProps>(\n TooltipContentInner,\n);\n","/**\n * Wraps a consumer-supplied event handler so the component's own handler runs\n * after it. Both always run — a tooltip never wants the consumer to be able to\n * silently suppress its open/close behavior.\n */\nexport function composeEventHandlers<E>(\n consumerHandler: ((event: E) => void) | undefined,\n ownHandler: (event: E) => void,\n): (event: E) => void {\n return (event: E) => {\n consumerHandler?.(event);\n ownHandler(event);\n };\n}\n","import {\n Children,\n cloneElement,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n} from 'react';\nimport { composeEventHandlers } from './composeEventHandlers';\nimport { useTooltipContext } from './TooltipContext';\n\nexport type TooltipTriggerProps = {\n /** A single focusable element. It is cloned, not wrapped. */\n children: ReactNode;\n};\n\ntype TriggerChildProps = {\n 'aria-describedby'?: string;\n onPointerEnter?: (event: PointerEvent) => void;\n onPointerLeave?: (event: PointerEvent) => void;\n onPointerDown?: (event: PointerEvent) => void;\n onFocus?: (event: React.FocusEvent) => void;\n onBlur?: (event: React.FocusEvent) => void;\n};\n\nexport function TooltipTrigger({\n children,\n}: TooltipTriggerProps): ReactElement {\n const { open, contentId, openNow, openWithDelay, close } =\n useTooltipContext();\n const child = Children.only(children) as ReactElement<TriggerChildProps>;\n const childProps = child.props;\n\n const describedBy = open\n ? [childProps['aria-describedby'], contentId].filter(Boolean).join(' ')\n : childProps['aria-describedby'];\n\n return cloneElement(child, {\n 'aria-describedby': describedBy,\n onPointerEnter: composeEventHandlers(\n childProps.onPointerEnter,\n openWithDelay,\n ),\n onPointerLeave: composeEventHandlers(childProps.onPointerLeave, close),\n onPointerDown: composeEventHandlers(childProps.onPointerDown, close),\n onFocus: composeEventHandlers(childProps.onFocus, openNow),\n onBlur: composeEventHandlers(childProps.onBlur, close),\n });\n}\n"]}
@@ -0,0 +1,5 @@
1
+ declare function getTooltip(): HTMLElement;
2
+ declare function queryTooltip(): HTMLElement | null;
3
+ declare function getTrigger(name: string | RegExp): HTMLElement;
4
+
5
+ export { getTooltip, getTrigger, queryTooltip };
@@ -0,0 +1,5 @@
1
+ declare function getTooltip(): HTMLElement;
2
+ declare function queryTooltip(): HTMLElement | null;
3
+ declare function getTrigger(name: string | RegExp): HTMLElement;
4
+
5
+ export { getTooltip, getTrigger, queryTooltip };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ var react = require('@testing-library/react');
4
+
5
+ // src/test-utils.ts
6
+ function getTooltip() {
7
+ return react.screen.getByRole("tooltip");
8
+ }
9
+ function queryTooltip() {
10
+ return react.screen.queryByRole("tooltip");
11
+ }
12
+ function getTrigger(name) {
13
+ return react.screen.getByRole("button", { name });
14
+ }
15
+
16
+ exports.getTooltip = getTooltip;
17
+ exports.getTrigger = getTrigger;
18
+ exports.queryTooltip = queryTooltip;
19
+ //# sourceMappingURL=test-utils.js.map
20
+ //# sourceMappingURL=test-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/test-utils.ts"],"names":["screen"],"mappings":";;;;;AAEO,SAAS,UAAA,GAA0B;AACxC,EAAA,OAAOA,YAAA,CAAO,UAAU,SAAS,CAAA;AACnC;AAEO,SAAS,YAAA,GAAmC;AACjD,EAAA,OAAOA,YAAA,CAAO,YAAY,SAAS,CAAA;AACrC;AAEO,SAAS,WAAW,IAAA,EAAoC;AAC7D,EAAA,OAAOA,YAAA,CAAO,SAAA,CAAU,QAAA,EAAU,EAAE,MAAM,CAAA;AAC5C","file":"test-utils.js","sourcesContent":["import { screen } from '@testing-library/react';\n\nexport function getTooltip(): HTMLElement {\n return screen.getByRole('tooltip');\n}\n\nexport function queryTooltip(): HTMLElement | null {\n return screen.queryByRole('tooltip');\n}\n\nexport function getTrigger(name: string | RegExp): HTMLElement {\n return screen.getByRole('button', { name });\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import { screen } from '@testing-library/react';
2
+
3
+ // src/test-utils.ts
4
+ function getTooltip() {
5
+ return screen.getByRole("tooltip");
6
+ }
7
+ function queryTooltip() {
8
+ return screen.queryByRole("tooltip");
9
+ }
10
+ function getTrigger(name) {
11
+ return screen.getByRole("button", { name });
12
+ }
13
+
14
+ export { getTooltip, getTrigger, queryTooltip };
15
+ //# sourceMappingURL=test-utils.mjs.map
16
+ //# sourceMappingURL=test-utils.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/test-utils.ts"],"names":[],"mappings":";;;AAEO,SAAS,UAAA,GAA0B;AACxC,EAAA,OAAO,MAAA,CAAO,UAAU,SAAS,CAAA;AACnC;AAEO,SAAS,YAAA,GAAmC;AACjD,EAAA,OAAO,MAAA,CAAO,YAAY,SAAS,CAAA;AACrC;AAEO,SAAS,WAAW,IAAA,EAAoC;AAC7D,EAAA,OAAO,MAAA,CAAO,SAAA,CAAU,QAAA,EAAU,EAAE,MAAM,CAAA;AAC5C","file":"test-utils.mjs","sourcesContent":["import { screen } from '@testing-library/react';\n\nexport function getTooltip(): HTMLElement {\n return screen.getByRole('tooltip');\n}\n\nexport function queryTooltip(): HTMLElement | null {\n return screen.queryByRole('tooltip');\n}\n\nexport function getTrigger(name: string | RegExp): HTMLElement {\n return screen.getByRole('button', { name });\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@asnewyla/unstyled-tooltip",
3
+ "version": "0.2.0",
4
+ "description": "Unstyled tooltip primitive — hover/focus open with delay, Escape to dismiss, accessible aria-describedby wiring",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/xavierDelaFuente/xd-components.git",
9
+ "directory": "packages/unstyled-tooltip"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "main": "dist/index.js",
15
+ "module": "dist/index.mjs",
16
+ "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.mjs",
21
+ "require": "./dist/index.js"
22
+ },
23
+ "./test-utils": {
24
+ "types": "./dist/test-utils.d.ts",
25
+ "import": "./dist/test-utils.mjs",
26
+ "require": "./dist/test-utils.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "sideEffects": false,
33
+ "keywords": [
34
+ "react",
35
+ "tooltip",
36
+ "unstyled",
37
+ "accessible"
38
+ ],
39
+ "peerDependencies": {
40
+ "react": "^18.0.0 || ^19.0.0",
41
+ "react-dom": "^18.0.0 || ^19.0.0",
42
+ "@testing-library/react": "^16.0.0",
43
+ "@testing-library/user-event": "^14.5.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@testing-library/react": {
47
+ "optional": true
48
+ },
49
+ "@testing-library/user-event": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "react": "^18.3.0",
55
+ "react-dom": "^18.3.0",
56
+ "tsup": "^8.1.0",
57
+ "vitest": "^2.0.0",
58
+ "@vitest/coverage-v8": "^2.0.0",
59
+ "@testing-library/react": "^16.0.0",
60
+ "@testing-library/user-event": "^14.5.0",
61
+ "@testing-library/jest-dom": "^6.4.0",
62
+ "jsdom": "^24.0.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "scripts": {
66
+ "build": "tsup",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest",
69
+ "test:coverage": "vitest run --coverage",
70
+ "type-check": "tsc --noEmit"
71
+ }
72
+ }