@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 +21 -0
- package/README.md +58 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +140 -0
- package/dist/index.mjs.map +1 -0
- package/dist/test-utils.d.mts +5 -0
- package/dist/test-utils.d.ts +5 -0
- package/dist/test-utils.js +20 -0
- package/dist/test-utils.js.map +1 -0
- package/dist/test-utils.mjs +16 -0
- package/dist/test-utils.mjs.map +1 -0
- package/package.json +72 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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,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
|
+
}
|