@carbonid1/design-system 5.7.5 → 5.7.7
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.
|
@@ -11,5 +11,6 @@ type TooltipProps = {
|
|
|
11
11
|
'aria-label'?: string;
|
|
12
12
|
}>;
|
|
13
13
|
};
|
|
14
|
+
export declare const VIEWPORT_MARGIN = 8;
|
|
14
15
|
export declare const Tooltip: ({ label, shortcut, position, delay, maxWidth, disabled, className, children, }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
|
|
15
16
|
export {};
|
package/dist/Tooltip/Tooltip.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { computeTooltipPlacement, } from '../helpers/tooltipPlacement/tooltipPlacement';
|
|
3
4
|
import { Kbd } from '../Kbd/Kbd';
|
|
4
|
-
import { cloneElement, useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
5
7
|
const DEFAULT_DELAY = 200;
|
|
8
|
+
export const VIEWPORT_MARGIN = 8;
|
|
9
|
+
// Gap in px between the trigger and the tooltip box, baked into the fixed `top`
|
|
10
|
+
// offset below; the flip math subtracts it when deciding whether a side fits.
|
|
11
|
+
const TRIGGER_GAP = 8;
|
|
12
|
+
// Position before paint so an edge tooltip never flashes centered-then-shifted.
|
|
13
|
+
// useLayoutEffect warns and no-ops during SSR, so fall back to useEffect there.
|
|
14
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
6
15
|
export const Tooltip = ({ label, shortcut, position = 'top', delay = DEFAULT_DELAY, maxWidth, disabled, className, children, }) => {
|
|
7
16
|
const [visible, setVisible] = useState(false);
|
|
17
|
+
const [box, setBox] = useState(null);
|
|
8
18
|
const timeoutRef = useRef(null);
|
|
9
19
|
const wrapperRef = useRef(null);
|
|
20
|
+
const tooltipRef = useRef(null);
|
|
10
21
|
const show = useCallback(() => {
|
|
11
22
|
if (disabled)
|
|
12
23
|
return;
|
|
@@ -52,8 +63,59 @@ export const Tooltip = ({ label, shortcut, position = 'top', delay = DEFAULT_DEL
|
|
|
52
63
|
document.addEventListener('pointermove', handlePointerMove, { passive: true });
|
|
53
64
|
return () => document.removeEventListener('pointermove', handlePointerMove);
|
|
54
65
|
}, [visible, hide]);
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
useIsomorphicLayoutEffect(() => {
|
|
67
|
+
if (!visible)
|
|
68
|
+
return;
|
|
69
|
+
const measure = () => {
|
|
70
|
+
const wrapper = wrapperRef.current;
|
|
71
|
+
const tooltip = tooltipRef.current;
|
|
72
|
+
if (!wrapper || !tooltip)
|
|
73
|
+
return;
|
|
74
|
+
const trigger = wrapper.getBoundingClientRect();
|
|
75
|
+
const tip = tooltip.getBoundingClientRect();
|
|
76
|
+
const placement = computeTooltipPlacement({
|
|
77
|
+
trigger,
|
|
78
|
+
tooltip: { width: tip.width, height: tip.height },
|
|
79
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
80
|
+
preferredSide: position,
|
|
81
|
+
margin: VIEWPORT_MARGIN,
|
|
82
|
+
gap: TRIGGER_GAP,
|
|
83
|
+
});
|
|
84
|
+
const left = trigger.left + trigger.width / 2;
|
|
85
|
+
const top = placement.side === 'top' ? trigger.top - TRIGGER_GAP : trigger.bottom + TRIGGER_GAP;
|
|
86
|
+
// Keep the same object when nothing moved so a no-op resize/scroll doesn't
|
|
87
|
+
// trigger a redundant re-render (the common centered, still case is a no-op).
|
|
88
|
+
setBox(prev => prev &&
|
|
89
|
+
prev.side === placement.side &&
|
|
90
|
+
prev.offsetX === placement.offsetX &&
|
|
91
|
+
prev.left === left &&
|
|
92
|
+
prev.top === top
|
|
93
|
+
? prev
|
|
94
|
+
: { ...placement, left, top });
|
|
95
|
+
};
|
|
96
|
+
measure();
|
|
97
|
+
// Portaled and fixed, the box no longer rides the trigger's scroll container,
|
|
98
|
+
// so re-anchor on scroll (capture, to catch any scrolling ancestor) and resize.
|
|
99
|
+
window.addEventListener('resize', measure, { passive: true });
|
|
100
|
+
window.addEventListener('scroll', measure, { capture: true, passive: true });
|
|
101
|
+
return () => {
|
|
102
|
+
window.removeEventListener('resize', measure);
|
|
103
|
+
window.removeEventListener('scroll', measure, { capture: true });
|
|
104
|
+
};
|
|
105
|
+
}, [visible, position]);
|
|
106
|
+
const boxStyle = {
|
|
107
|
+
position: 'fixed',
|
|
108
|
+
left: box?.left ?? 0,
|
|
109
|
+
top: box?.top ?? 0,
|
|
110
|
+
transform: `translate(calc(-50% + ${box?.offsetX ?? 0}px), ${box?.side === 'top' ? '-100%' : '0'})`,
|
|
111
|
+
// Hidden until measured so the first frame never paints at the un-anchored origin.
|
|
112
|
+
visibility: box ? 'visible' : 'hidden',
|
|
113
|
+
...(maxWidth ? { maxWidth } : {}),
|
|
114
|
+
};
|
|
115
|
+
return (_jsxs("div", { ref: wrapperRef, className: `inline-flex${className ? ` ${className}` : ''}`, onMouseEnter: show, onMouseLeave: hide, onPointerDown: hide, onFocus: show, onBlur: hide, children: [cloneElement(children, {
|
|
57
116
|
'aria-label': children.props['aria-label'] ?? label,
|
|
58
|
-
}), visible &&
|
|
117
|
+
}), visible &&
|
|
118
|
+
!disabled &&
|
|
119
|
+
typeof document !== 'undefined' &&
|
|
120
|
+
createPortal(_jsxs("div", { ref: tooltipRef, role: "tooltip", style: boxStyle, className: `bg-foreground text-background pointer-events-none z-50 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs shadow-lg ${maxWidth ? 'w-max whitespace-normal' : 'whitespace-nowrap'}`, children: [label, shortcut && (_jsx(Kbd, { keys: shortcut, size: "sm", className: "bg-background/15 border-transparent" }))] }), document.body)] }));
|
|
59
121
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type Side = 'top' | 'bottom';
|
|
2
|
+
type Rect = {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
width: number;
|
|
7
|
+
};
|
|
8
|
+
type Size = {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
type TooltipPlacementInput = {
|
|
13
|
+
/** Trigger (wrapper) rect, viewport-relative — the tooltip is centered on its horizontal midpoint. */
|
|
14
|
+
trigger: Rect;
|
|
15
|
+
/** Measured tooltip box, taken at its un-shifted centered position. */
|
|
16
|
+
tooltip: Size;
|
|
17
|
+
viewport: Size;
|
|
18
|
+
/** Side the caller asked for; kept unless that side lacks room. */
|
|
19
|
+
preferredSide: Side;
|
|
20
|
+
/** Minimum gap to keep between the tooltip and each viewport edge. */
|
|
21
|
+
margin: number;
|
|
22
|
+
/** Gap between the trigger and the tooltip on the chosen side. */
|
|
23
|
+
gap: number;
|
|
24
|
+
};
|
|
25
|
+
export type TooltipPlacement = {
|
|
26
|
+
side: Side;
|
|
27
|
+
/** Pixels to shift the centered tooltip horizontally; 0 leaves it centered. */
|
|
28
|
+
offsetX: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Decide where a centered tooltip should sit so it stays within the viewport:
|
|
32
|
+
* shift it horizontally when an edge would cross `margin`, and flip the vertical
|
|
33
|
+
* side when the preferred one lacks room. Pure — the component feeds it measured
|
|
34
|
+
* rects and applies the result. On a viewport too narrow to fit the box, the left
|
|
35
|
+
* edge wins (LTR readers lose the tail, not the start).
|
|
36
|
+
*/
|
|
37
|
+
export declare const computeTooltipPlacement: ({ trigger, tooltip, viewport, preferredSide, margin, gap, }: TooltipPlacementInput) => TooltipPlacement;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide where a centered tooltip should sit so it stays within the viewport:
|
|
3
|
+
* shift it horizontally when an edge would cross `margin`, and flip the vertical
|
|
4
|
+
* side when the preferred one lacks room. Pure — the component feeds it measured
|
|
5
|
+
* rects and applies the result. On a viewport too narrow to fit the box, the left
|
|
6
|
+
* edge wins (LTR readers lose the tail, not the start).
|
|
7
|
+
*/
|
|
8
|
+
export const computeTooltipPlacement = ({ trigger, tooltip, viewport, preferredSide, margin, gap, }) => {
|
|
9
|
+
const triggerCenterX = trigger.left + trigger.width / 2;
|
|
10
|
+
const centeredLeft = triggerCenterX - tooltip.width / 2;
|
|
11
|
+
const centeredRight = centeredLeft + tooltip.width;
|
|
12
|
+
let offsetX = 0;
|
|
13
|
+
if (centeredRight > viewport.width - margin)
|
|
14
|
+
offsetX = viewport.width - margin - centeredRight;
|
|
15
|
+
if (centeredLeft + offsetX < margin)
|
|
16
|
+
offsetX = margin - centeredLeft;
|
|
17
|
+
const spaceAbove = trigger.top;
|
|
18
|
+
const spaceBelow = viewport.height - trigger.bottom;
|
|
19
|
+
const needed = tooltip.height + gap + margin;
|
|
20
|
+
let side = preferredSide;
|
|
21
|
+
if (preferredSide === 'top' && spaceAbove < needed && spaceBelow > spaceAbove)
|
|
22
|
+
side = 'bottom';
|
|
23
|
+
if (preferredSide === 'bottom' && spaceBelow < needed && spaceAbove > spaceBelow)
|
|
24
|
+
side = 'top';
|
|
25
|
+
return { side, offsetX };
|
|
26
|
+
};
|