@udixio/ui-react 2.9.8 → 2.9.10
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/CHANGELOG.md +26 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +2572 -2446
- package/dist/lib/components/Button.d.ts +6 -0
- package/dist/lib/components/Button.d.ts.map +1 -1
- package/dist/lib/components/Card.d.ts +4 -0
- package/dist/lib/components/Card.d.ts.map +1 -1
- package/dist/lib/components/Carousel.d.ts +4 -0
- package/dist/lib/components/Carousel.d.ts.map +1 -1
- package/dist/lib/components/CarouselItem.d.ts +4 -0
- package/dist/lib/components/CarouselItem.d.ts.map +1 -1
- package/dist/lib/components/Chip.d.ts +7 -0
- package/dist/lib/components/Chip.d.ts.map +1 -1
- package/dist/lib/components/Chips.d.ts +10 -0
- package/dist/lib/components/Chips.d.ts.map +1 -1
- package/dist/lib/components/Divider.d.ts +2 -0
- package/dist/lib/components/Divider.d.ts.map +1 -1
- package/dist/lib/components/Fab.d.ts +4 -0
- package/dist/lib/components/Fab.d.ts.map +1 -1
- package/dist/lib/components/FabMenu.d.ts +7 -0
- package/dist/lib/components/FabMenu.d.ts.map +1 -1
- package/dist/lib/components/IconButton.d.ts +7 -2
- package/dist/lib/components/IconButton.d.ts.map +1 -1
- package/dist/lib/components/NavigationRail.d.ts +6 -0
- package/dist/lib/components/NavigationRail.d.ts.map +1 -1
- package/dist/lib/components/NavigationRailItem.d.ts +7 -0
- package/dist/lib/components/NavigationRailItem.d.ts.map +1 -1
- package/dist/lib/components/ProgressIndicator.d.ts +6 -0
- package/dist/lib/components/ProgressIndicator.d.ts.map +1 -1
- package/dist/lib/components/SideSheet.d.ts +7 -0
- package/dist/lib/components/SideSheet.d.ts.map +1 -1
- package/dist/lib/components/Slider.d.ts +5 -0
- package/dist/lib/components/Slider.d.ts.map +1 -1
- package/dist/lib/components/Snackbar.d.ts +6 -0
- package/dist/lib/components/Snackbar.d.ts.map +1 -1
- package/dist/lib/components/Switch.d.ts +4 -0
- package/dist/lib/components/Switch.d.ts.map +1 -1
- package/dist/lib/components/Tab.d.ts +5 -0
- package/dist/lib/components/Tab.d.ts.map +1 -1
- package/dist/lib/components/TabGroup.d.ts +4 -0
- package/dist/lib/components/TabGroup.d.ts.map +1 -1
- package/dist/lib/components/TabGroupContext.d.ts +4 -0
- package/dist/lib/components/TabGroupContext.d.ts.map +1 -1
- package/dist/lib/components/TabPanel.d.ts +2 -0
- package/dist/lib/components/TabPanel.d.ts.map +1 -1
- package/dist/lib/components/TabPanels.d.ts +4 -0
- package/dist/lib/components/TabPanels.d.ts.map +1 -1
- package/dist/lib/components/Tabs.d.ts +4 -0
- package/dist/lib/components/Tabs.d.ts.map +1 -1
- package/dist/lib/components/TextField.d.ts +5 -0
- package/dist/lib/components/TextField.d.ts.map +1 -1
- package/dist/lib/components/Tooltip.d.ts +14 -0
- package/dist/lib/components/Tooltip.d.ts.map +1 -0
- package/dist/lib/components/index.d.ts +1 -1
- package/dist/lib/hooks/index.d.ts +5 -0
- package/dist/lib/hooks/index.d.ts.map +1 -0
- package/dist/lib/hooks/useTooltipPosition.d.ts +22 -0
- package/dist/lib/hooks/useTooltipPosition.d.ts.map +1 -0
- package/dist/lib/hooks/useTooltipTrigger.d.ts +44 -0
- package/dist/lib/hooks/useTooltipTrigger.d.ts.map +1 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/interfaces/fab.interface.d.ts +2 -1
- package/dist/lib/interfaces/fab.interface.d.ts.map +1 -1
- package/dist/lib/interfaces/tooltip.interface.d.ts +24 -2
- package/dist/lib/interfaces/tooltip.interface.d.ts.map +1 -1
- package/dist/lib/styles/fab-menu.style.d.ts +4 -4
- package/dist/lib/styles/fab.style.d.ts +4 -4
- package/dist/lib/styles/icon-button.style.d.ts +2 -2
- package/dist/lib/styles/tooltip.style.d.ts +34 -6
- package/dist/lib/styles/tooltip.style.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/lib/components/Button.tsx +6 -0
- package/src/lib/components/Card.tsx +4 -0
- package/src/lib/components/Carousel.tsx +4 -0
- package/src/lib/components/CarouselItem.tsx +4 -0
- package/src/lib/components/Chip.tsx +7 -0
- package/src/lib/components/Chips.tsx +10 -0
- package/src/lib/components/Divider.tsx +2 -0
- package/src/lib/components/Fab.tsx +6 -2
- package/src/lib/components/FabMenu.tsx +7 -0
- package/src/lib/components/IconButton.tsx +12 -8
- package/src/lib/components/NavigationRail.tsx +6 -0
- package/src/lib/components/NavigationRailItem.tsx +7 -0
- package/src/lib/components/ProgressIndicator.tsx +6 -0
- package/src/lib/components/SideSheet.tsx +7 -0
- package/src/lib/components/Slider.tsx +5 -0
- package/src/lib/components/Snackbar.tsx +6 -0
- package/src/lib/components/Switch.tsx +4 -0
- package/src/lib/components/Tab.tsx +5 -0
- package/src/lib/components/TabGroup.tsx +4 -0
- package/src/lib/components/TabGroupContext.tsx +6 -1
- package/src/lib/components/TabPanel.tsx +2 -0
- package/src/lib/components/TabPanels.tsx +4 -0
- package/src/lib/components/Tabs.tsx +4 -0
- package/src/lib/components/TextField.tsx +5 -0
- package/src/lib/components/Tooltip.tsx +217 -0
- package/src/lib/components/index.ts +1 -1
- package/src/lib/hooks/index.ts +11 -0
- package/src/lib/hooks/useTooltipPosition.ts +95 -0
- package/src/lib/hooks/useTooltipTrigger.ts +270 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/interfaces/fab.interface.ts +2 -1
- package/src/lib/interfaces/tooltip.interface.ts +24 -2
- package/src/lib/styles/tooltip.style.ts +1 -0
- package/src/stories/communication/tool-tip.stories.tsx +19 -19
- package/dist/lib/components/ToolTip.d.ts +0 -9
- package/dist/lib/components/ToolTip.d.ts.map +0 -1
- package/src/lib/components/ToolTip.tsx +0 -256
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, useEffect, useRef } from 'react';
|
|
2
|
+
import { MotionProps } from '../utils';
|
|
3
|
+
import { Button } from './Button';
|
|
4
|
+
import { ToolTipInterface } from '../interfaces';
|
|
5
|
+
import { useToolTipStyle } from '../styles';
|
|
6
|
+
import { AnimatePresence, motion } from 'motion/react';
|
|
7
|
+
import { SyncedFixedWrapper } from '../effects';
|
|
8
|
+
import { useTooltipTrigger, useTooltipPosition } from '../hooks';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tooltips display brief labels or messages
|
|
12
|
+
* @status beta
|
|
13
|
+
* @category Communication
|
|
14
|
+
* @devx
|
|
15
|
+
* - `content` overrides `title`/`text`/`buttons` for fully custom content.
|
|
16
|
+
* - Supports controlled `isOpen` plus `openDelay`/`closeDelay`.
|
|
17
|
+
* @a11y
|
|
18
|
+
* - Provides `role="tooltip"` and `aria-describedby` when open.
|
|
19
|
+
*/
|
|
20
|
+
export const Tooltip = ({
|
|
21
|
+
variant = 'plain',
|
|
22
|
+
buttons,
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
title,
|
|
26
|
+
text,
|
|
27
|
+
content,
|
|
28
|
+
position: positionProp,
|
|
29
|
+
targetRef,
|
|
30
|
+
ref,
|
|
31
|
+
trigger = ['hover', 'focus'],
|
|
32
|
+
transition,
|
|
33
|
+
openDelay = 400,
|
|
34
|
+
closeDelay = 150,
|
|
35
|
+
isOpen: isOpenProp,
|
|
36
|
+
defaultOpen = false,
|
|
37
|
+
onOpenChange,
|
|
38
|
+
id,
|
|
39
|
+
...props
|
|
40
|
+
}: MotionProps<ToolTipInterface>) => {
|
|
41
|
+
transition = { duration: 0.3, ...transition };
|
|
42
|
+
|
|
43
|
+
if (!children && !targetRef) {
|
|
44
|
+
throw new Error('Tooltip must have a child or a targetRef');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (buttons && !Array.isArray(buttons)) {
|
|
48
|
+
buttons = [buttons];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const internalRef = useRef<HTMLElement | null>(null);
|
|
52
|
+
const resolvedRef = targetRef || internalRef;
|
|
53
|
+
|
|
54
|
+
// Use the trigger hook for state management and accessibility
|
|
55
|
+
const { triggerProps, tooltipProps, isOpen } = useTooltipTrigger({
|
|
56
|
+
trigger,
|
|
57
|
+
isOpen: isOpenProp,
|
|
58
|
+
defaultOpen,
|
|
59
|
+
onOpenChange,
|
|
60
|
+
openDelay,
|
|
61
|
+
closeDelay,
|
|
62
|
+
id,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Use the position hook for auto-positioning
|
|
66
|
+
const { resolvedPosition } = useTooltipPosition({
|
|
67
|
+
targetRef: resolvedRef,
|
|
68
|
+
position: positionProp,
|
|
69
|
+
variant,
|
|
70
|
+
isOpen,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Apply trigger props to the target element
|
|
74
|
+
const enhancedChildren =
|
|
75
|
+
!targetRef && isValidElement(children)
|
|
76
|
+
? cloneElement(children, {
|
|
77
|
+
ref: internalRef,
|
|
78
|
+
...triggerProps,
|
|
79
|
+
// Merge event handlers if the child already has them
|
|
80
|
+
onMouseEnter: (e: React.MouseEvent) => {
|
|
81
|
+
triggerProps.onMouseEnter();
|
|
82
|
+
(children.props as any)?.onMouseEnter?.(e);
|
|
83
|
+
},
|
|
84
|
+
onMouseLeave: (e: React.MouseEvent) => {
|
|
85
|
+
triggerProps.onMouseLeave();
|
|
86
|
+
(children.props as any)?.onMouseLeave?.(e);
|
|
87
|
+
},
|
|
88
|
+
onFocus: (e: React.FocusEvent) => {
|
|
89
|
+
triggerProps.onFocus();
|
|
90
|
+
(children.props as any)?.onFocus?.(e);
|
|
91
|
+
},
|
|
92
|
+
onBlur: (e: React.FocusEvent) => {
|
|
93
|
+
triggerProps.onBlur();
|
|
94
|
+
(children.props as any)?.onBlur?.(e);
|
|
95
|
+
},
|
|
96
|
+
onClick: (e: React.MouseEvent) => {
|
|
97
|
+
triggerProps.onClick();
|
|
98
|
+
(children.props as any)?.onClick?.(e);
|
|
99
|
+
},
|
|
100
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
101
|
+
triggerProps.onKeyDown(e);
|
|
102
|
+
(children.props as any)?.onKeyDown?.(e);
|
|
103
|
+
},
|
|
104
|
+
} as any)
|
|
105
|
+
: children;
|
|
106
|
+
|
|
107
|
+
// Attach trigger handlers when using targetRef (no direct child to clone)
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!targetRef) return;
|
|
110
|
+
const element = targetRef.current;
|
|
111
|
+
if (!element) return;
|
|
112
|
+
|
|
113
|
+
const handleMouseEnter = () => triggerProps.onMouseEnter();
|
|
114
|
+
const handleMouseLeave = () => triggerProps.onMouseLeave();
|
|
115
|
+
const handleFocus = () => triggerProps.onFocus();
|
|
116
|
+
const handleBlur = () => triggerProps.onBlur();
|
|
117
|
+
const handleClick = () => triggerProps.onClick();
|
|
118
|
+
const handleKeyDown = (event: KeyboardEvent) =>
|
|
119
|
+
triggerProps.onKeyDown(event as unknown as React.KeyboardEvent);
|
|
120
|
+
|
|
121
|
+
element.addEventListener('mouseenter', handleMouseEnter);
|
|
122
|
+
element.addEventListener('mouseleave', handleMouseLeave);
|
|
123
|
+
element.addEventListener('focus', handleFocus, true);
|
|
124
|
+
element.addEventListener('blur', handleBlur, true);
|
|
125
|
+
element.addEventListener('click', handleClick);
|
|
126
|
+
element.addEventListener('keydown', handleKeyDown);
|
|
127
|
+
|
|
128
|
+
if (triggerProps['aria-describedby']) {
|
|
129
|
+
element.setAttribute(
|
|
130
|
+
'aria-describedby',
|
|
131
|
+
triggerProps['aria-describedby'],
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
element.removeAttribute('aria-describedby');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
element.removeEventListener('mouseenter', handleMouseEnter);
|
|
139
|
+
element.removeEventListener('mouseleave', handleMouseLeave);
|
|
140
|
+
element.removeEventListener('focus', handleFocus, true);
|
|
141
|
+
element.removeEventListener('blur', handleBlur, true);
|
|
142
|
+
element.removeEventListener('click', handleClick);
|
|
143
|
+
element.removeEventListener('keydown', handleKeyDown);
|
|
144
|
+
};
|
|
145
|
+
}, [targetRef, triggerProps]);
|
|
146
|
+
|
|
147
|
+
const styles = useToolTipStyle({
|
|
148
|
+
variant,
|
|
149
|
+
buttons,
|
|
150
|
+
className,
|
|
151
|
+
title,
|
|
152
|
+
text,
|
|
153
|
+
position: resolvedPosition,
|
|
154
|
+
trigger,
|
|
155
|
+
targetRef: targetRef as any,
|
|
156
|
+
children: children as any,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const variants = {
|
|
160
|
+
open: {
|
|
161
|
+
opacity: 1,
|
|
162
|
+
height: 'auto',
|
|
163
|
+
},
|
|
164
|
+
close: {
|
|
165
|
+
opacity: 0,
|
|
166
|
+
height: 16,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
{enhancedChildren}
|
|
173
|
+
<AnimatePresence>
|
|
174
|
+
{isOpen && (
|
|
175
|
+
<SyncedFixedWrapper targetRef={resolvedRef}>
|
|
176
|
+
<motion.div
|
|
177
|
+
initial={'close'}
|
|
178
|
+
variants={variants}
|
|
179
|
+
animate={'open'}
|
|
180
|
+
transition={{ duration: transition.duration }}
|
|
181
|
+
exit={'close'}
|
|
182
|
+
className={styles.toolTip}
|
|
183
|
+
{...props}
|
|
184
|
+
{...tooltipProps}
|
|
185
|
+
>
|
|
186
|
+
<div className={styles.container}>
|
|
187
|
+
{content ? (
|
|
188
|
+
<div className={styles.content}>{content}</div>
|
|
189
|
+
) : (
|
|
190
|
+
<>
|
|
191
|
+
{title && <div className={styles.subHead}>{title}</div>}
|
|
192
|
+
{text && (
|
|
193
|
+
<div className={styles.supportingText}>{text}</div>
|
|
194
|
+
)}
|
|
195
|
+
{buttons && (
|
|
196
|
+
<div className={styles.actions}>
|
|
197
|
+
{Array.isArray(buttons) &&
|
|
198
|
+
buttons.map((buttonArgs, index) => (
|
|
199
|
+
<Button
|
|
200
|
+
key={index}
|
|
201
|
+
size={'small'}
|
|
202
|
+
variant={'text'}
|
|
203
|
+
{...buttonArgs}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</motion.div>
|
|
212
|
+
</SyncedFixedWrapper>
|
|
213
|
+
)}
|
|
214
|
+
</AnimatePresence>
|
|
215
|
+
</>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { useTooltipTrigger } from './useTooltipTrigger';
|
|
2
|
+
export type {
|
|
3
|
+
UseTooltipTriggerOptions,
|
|
4
|
+
UseTooltipTriggerReturn,
|
|
5
|
+
} from './useTooltipTrigger';
|
|
6
|
+
|
|
7
|
+
export { useTooltipPosition } from './useTooltipPosition';
|
|
8
|
+
export type {
|
|
9
|
+
UseTooltipPositionOptions,
|
|
10
|
+
UseTooltipPositionReturn,
|
|
11
|
+
} from './useTooltipPosition';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { RefObject, useLayoutEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type Position =
|
|
4
|
+
| 'top'
|
|
5
|
+
| 'bottom'
|
|
6
|
+
| 'left'
|
|
7
|
+
| 'right'
|
|
8
|
+
| 'top-left'
|
|
9
|
+
| 'top-right'
|
|
10
|
+
| 'bottom-left'
|
|
11
|
+
| 'bottom-right';
|
|
12
|
+
|
|
13
|
+
type Variant = 'plain' | 'rich';
|
|
14
|
+
|
|
15
|
+
export interface UseTooltipPositionOptions {
|
|
16
|
+
targetRef: RefObject<HTMLElement | null>;
|
|
17
|
+
position?: Position;
|
|
18
|
+
variant?: Variant;
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseTooltipPositionReturn {
|
|
23
|
+
resolvedPosition: Position;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hook to calculate tooltip position using useLayoutEffect.
|
|
28
|
+
* Auto-flips position if not enough viewport space.
|
|
29
|
+
*
|
|
30
|
+
* For plain variant: prefers left/right, falls back to top/bottom
|
|
31
|
+
* For rich variant: uses corner positions (top-left, top-right, bottom-left, bottom-right)
|
|
32
|
+
*/
|
|
33
|
+
export function useTooltipPosition({
|
|
34
|
+
targetRef,
|
|
35
|
+
position: positionProp,
|
|
36
|
+
variant = 'plain',
|
|
37
|
+
isOpen,
|
|
38
|
+
}: UseTooltipPositionOptions): UseTooltipPositionReturn {
|
|
39
|
+
const [resolvedPosition, setResolvedPosition] = useState<Position>(
|
|
40
|
+
positionProp ?? 'bottom',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
// If position is explicitly set, use it
|
|
45
|
+
if (positionProp) {
|
|
46
|
+
setResolvedPosition(positionProp);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Only calculate if open and we have a target
|
|
51
|
+
if (!isOpen || !targetRef.current || typeof window === 'undefined') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const targetElement = targetRef.current;
|
|
56
|
+
const rect = targetElement.getBoundingClientRect();
|
|
57
|
+
|
|
58
|
+
const viewportWidth = window.innerWidth;
|
|
59
|
+
const viewportHeight = window.innerHeight;
|
|
60
|
+
|
|
61
|
+
// Normalized position (0-1 range)
|
|
62
|
+
const x = rect.left / viewportWidth;
|
|
63
|
+
const y = rect.top / viewportHeight;
|
|
64
|
+
|
|
65
|
+
let newPosition: Position;
|
|
66
|
+
|
|
67
|
+
if (variant === 'plain') {
|
|
68
|
+
// Plain variant: prefer horizontal positioning, fall back to vertical
|
|
69
|
+
if (x < 1 / 3) {
|
|
70
|
+
newPosition = 'right';
|
|
71
|
+
} else if (x > 2 / 3) {
|
|
72
|
+
newPosition = 'left';
|
|
73
|
+
} else {
|
|
74
|
+
newPosition = y > 0.5 ? 'top' : 'bottom';
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
// Rich variant: use corner positions
|
|
78
|
+
if (x < 0.5 && y < 0.5) {
|
|
79
|
+
newPosition = 'bottom-right';
|
|
80
|
+
} else if (x >= 0.5 && y < 0.5) {
|
|
81
|
+
newPosition = 'bottom-left';
|
|
82
|
+
} else if (x >= 0.5 && y >= 0.5) {
|
|
83
|
+
newPosition = 'top-left';
|
|
84
|
+
} else {
|
|
85
|
+
newPosition = 'top-right';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setResolvedPosition(newPosition);
|
|
90
|
+
}, [isOpen, targetRef, positionProp, variant]);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
resolvedPosition,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type Trigger = 'hover' | 'click' | 'focus' | null;
|
|
4
|
+
|
|
5
|
+
type TooltipState = 'hidden' | 'hovered' | 'focused' | 'clicked';
|
|
6
|
+
|
|
7
|
+
export interface UseTooltipTriggerOptions {
|
|
8
|
+
trigger?: Trigger | Trigger[];
|
|
9
|
+
isOpen?: boolean;
|
|
10
|
+
defaultOpen?: boolean;
|
|
11
|
+
onOpenChange?: (open: boolean) => void;
|
|
12
|
+
openDelay?: number;
|
|
13
|
+
closeDelay?: number;
|
|
14
|
+
id?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseTooltipTriggerReturn {
|
|
18
|
+
triggerProps: {
|
|
19
|
+
'aria-describedby': string | undefined;
|
|
20
|
+
onMouseEnter: () => void;
|
|
21
|
+
onMouseLeave: () => void;
|
|
22
|
+
onFocus: () => void;
|
|
23
|
+
onBlur: () => void;
|
|
24
|
+
onClick: () => void;
|
|
25
|
+
onKeyDown: (event: React.KeyboardEvent) => void;
|
|
26
|
+
};
|
|
27
|
+
tooltipProps: {
|
|
28
|
+
id: string;
|
|
29
|
+
role: 'tooltip';
|
|
30
|
+
'aria-hidden': boolean;
|
|
31
|
+
onMouseEnter: () => void;
|
|
32
|
+
onMouseLeave: () => void;
|
|
33
|
+
};
|
|
34
|
+
isOpen: boolean;
|
|
35
|
+
state: TooltipState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hook to manage tooltip trigger state machine, events, and accessibility props.
|
|
40
|
+
*
|
|
41
|
+
* State Machine:
|
|
42
|
+
* - States: hidden | hovered | focused | clicked
|
|
43
|
+
* - Priority: clicked > focused > hovered > hidden
|
|
44
|
+
* - Focus takes priority over hover (don't close on mouse leave if focused)
|
|
45
|
+
* - Escape key closes tooltip from any open state
|
|
46
|
+
* - Click toggles for 'click' trigger
|
|
47
|
+
*/
|
|
48
|
+
export function useTooltipTrigger({
|
|
49
|
+
trigger = ['hover', 'focus'],
|
|
50
|
+
isOpen: isOpenProp,
|
|
51
|
+
defaultOpen = false,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
openDelay = 400,
|
|
54
|
+
closeDelay = 150,
|
|
55
|
+
id: idProp,
|
|
56
|
+
}: UseTooltipTriggerOptions = {}): UseTooltipTriggerReturn {
|
|
57
|
+
const generatedId = useId();
|
|
58
|
+
const tooltipId = idProp ?? `tooltip-${generatedId}`;
|
|
59
|
+
|
|
60
|
+
// Normalize trigger to array
|
|
61
|
+
const triggers = Array.isArray(trigger) ? trigger : [trigger];
|
|
62
|
+
|
|
63
|
+
// Controlled vs uncontrolled state
|
|
64
|
+
const isControlled = typeof isOpenProp === 'boolean';
|
|
65
|
+
const [internalState, setInternalState] = useState<TooltipState>(
|
|
66
|
+
defaultOpen ? 'hovered' : 'hidden',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Track if tooltip content is being hovered (for pointer intent)
|
|
70
|
+
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
|
|
71
|
+
|
|
72
|
+
// Timeout refs for delayed open/close
|
|
73
|
+
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
74
|
+
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
75
|
+
|
|
76
|
+
// Clear all timeouts
|
|
77
|
+
const clearTimeouts = useCallback(() => {
|
|
78
|
+
if (openTimeoutRef.current) {
|
|
79
|
+
clearTimeout(openTimeoutRef.current);
|
|
80
|
+
openTimeoutRef.current = null;
|
|
81
|
+
}
|
|
82
|
+
if (closeTimeoutRef.current) {
|
|
83
|
+
clearTimeout(closeTimeoutRef.current);
|
|
84
|
+
closeTimeoutRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
// Cleanup on unmount
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
return () => clearTimeouts();
|
|
91
|
+
}, [clearTimeouts]);
|
|
92
|
+
|
|
93
|
+
// State transition function
|
|
94
|
+
const transition = useCallback(
|
|
95
|
+
(newState: TooltipState) => {
|
|
96
|
+
if (isControlled) {
|
|
97
|
+
// In controlled mode, notify parent of desired state change
|
|
98
|
+
const shouldBeOpen = newState !== 'hidden';
|
|
99
|
+
onOpenChange?.(shouldBeOpen);
|
|
100
|
+
} else {
|
|
101
|
+
setInternalState(newState);
|
|
102
|
+
const shouldBeOpen = newState !== 'hidden';
|
|
103
|
+
onOpenChange?.(shouldBeOpen);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[isControlled, onOpenChange],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Compute actual state and isOpen
|
|
110
|
+
const state = isControlled
|
|
111
|
+
? isOpenProp
|
|
112
|
+
? 'hovered' // Simplified: in controlled mode, we just track open/closed
|
|
113
|
+
: 'hidden'
|
|
114
|
+
: internalState;
|
|
115
|
+
|
|
116
|
+
const isOpen = state !== 'hidden';
|
|
117
|
+
|
|
118
|
+
// Get state priority for comparison
|
|
119
|
+
const getStatePriority = (s: TooltipState): number => {
|
|
120
|
+
switch (s) {
|
|
121
|
+
case 'hidden':
|
|
122
|
+
return 0;
|
|
123
|
+
case 'hovered':
|
|
124
|
+
return 1;
|
|
125
|
+
case 'focused':
|
|
126
|
+
return 2;
|
|
127
|
+
case 'clicked':
|
|
128
|
+
return 3;
|
|
129
|
+
default:
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Schedule opening with delay
|
|
135
|
+
const scheduleOpen = useCallback(
|
|
136
|
+
(targetState: TooltipState) => {
|
|
137
|
+
clearTimeouts();
|
|
138
|
+
|
|
139
|
+
// Only transition if new state has higher priority
|
|
140
|
+
if (getStatePriority(targetState) <= getStatePriority(state)) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
openTimeoutRef.current = setTimeout(() => {
|
|
145
|
+
transition(targetState);
|
|
146
|
+
}, openDelay);
|
|
147
|
+
},
|
|
148
|
+
[clearTimeouts, openDelay, state, transition],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Schedule closing with delay
|
|
152
|
+
const scheduleClose = useCallback(
|
|
153
|
+
(fromState: TooltipState) => {
|
|
154
|
+
clearTimeouts();
|
|
155
|
+
|
|
156
|
+
closeTimeoutRef.current = setTimeout(() => {
|
|
157
|
+
// Only close if we're still in the same state (or lower priority)
|
|
158
|
+
if (
|
|
159
|
+
!isControlled &&
|
|
160
|
+
getStatePriority(internalState) <= getStatePriority(fromState)
|
|
161
|
+
) {
|
|
162
|
+
transition('hidden');
|
|
163
|
+
} else if (isControlled) {
|
|
164
|
+
transition('hidden');
|
|
165
|
+
}
|
|
166
|
+
}, closeDelay);
|
|
167
|
+
},
|
|
168
|
+
[clearTimeouts, closeDelay, internalState, isControlled, transition],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Event handlers for trigger element
|
|
172
|
+
const handleMouseEnter = useCallback(() => {
|
|
173
|
+
if (!triggers.includes('hover')) return;
|
|
174
|
+
scheduleOpen('hovered');
|
|
175
|
+
}, [triggers, scheduleOpen]);
|
|
176
|
+
|
|
177
|
+
const handleMouseLeave = useCallback(() => {
|
|
178
|
+
if (!triggers.includes('hover')) return;
|
|
179
|
+
|
|
180
|
+
// Don't close if focused (focus has higher priority)
|
|
181
|
+
if (state === 'focused' || state === 'clicked') return;
|
|
182
|
+
|
|
183
|
+
// Don't close immediately if tooltip itself is hovered
|
|
184
|
+
if (isTooltipHovered) return;
|
|
185
|
+
|
|
186
|
+
scheduleClose('hovered');
|
|
187
|
+
}, [triggers, state, isTooltipHovered, scheduleClose]);
|
|
188
|
+
|
|
189
|
+
const handleFocus = useCallback(() => {
|
|
190
|
+
if (!triggers.includes('focus')) return;
|
|
191
|
+
clearTimeouts();
|
|
192
|
+
transition('focused');
|
|
193
|
+
}, [triggers, clearTimeouts, transition]);
|
|
194
|
+
|
|
195
|
+
const handleBlur = useCallback(() => {
|
|
196
|
+
if (!triggers.includes('focus')) return;
|
|
197
|
+
|
|
198
|
+
// Don't close if clicked (clicked has higher priority)
|
|
199
|
+
if (state === 'clicked') return;
|
|
200
|
+
|
|
201
|
+
// If also hovering, transition to hovered state
|
|
202
|
+
if (triggers.includes('hover') && isTooltipHovered) {
|
|
203
|
+
transition('hovered');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
scheduleClose('focused');
|
|
208
|
+
}, [triggers, state, isTooltipHovered, scheduleClose, transition]);
|
|
209
|
+
|
|
210
|
+
const handleClick = useCallback(() => {
|
|
211
|
+
if (!triggers.includes('click')) return;
|
|
212
|
+
|
|
213
|
+
clearTimeouts();
|
|
214
|
+
|
|
215
|
+
// Toggle behavior for click trigger
|
|
216
|
+
if (state === 'clicked') {
|
|
217
|
+
transition('hidden');
|
|
218
|
+
} else {
|
|
219
|
+
transition('clicked');
|
|
220
|
+
}
|
|
221
|
+
}, [triggers, state, clearTimeouts, transition]);
|
|
222
|
+
|
|
223
|
+
const handleKeyDown = useCallback(
|
|
224
|
+
(event: React.KeyboardEvent) => {
|
|
225
|
+
// Escape closes tooltip from any open state
|
|
226
|
+
if (event.key === 'Escape' && isOpen) {
|
|
227
|
+
clearTimeouts();
|
|
228
|
+
transition('hidden');
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
[isOpen, clearTimeouts, transition],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Event handlers for tooltip element (pointer intent)
|
|
236
|
+
const handleTooltipMouseEnter = useCallback(() => {
|
|
237
|
+
setIsTooltipHovered(true);
|
|
238
|
+
clearTimeouts();
|
|
239
|
+
}, [clearTimeouts]);
|
|
240
|
+
|
|
241
|
+
const handleTooltipMouseLeave = useCallback(() => {
|
|
242
|
+
setIsTooltipHovered(false);
|
|
243
|
+
|
|
244
|
+
// If trigger includes hover and we're in hover state, schedule close
|
|
245
|
+
if (triggers.includes('hover') && state === 'hovered') {
|
|
246
|
+
scheduleClose('hovered');
|
|
247
|
+
}
|
|
248
|
+
}, [triggers, state, scheduleClose]);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
triggerProps: {
|
|
252
|
+
'aria-describedby': isOpen ? tooltipId : undefined,
|
|
253
|
+
onMouseEnter: handleMouseEnter,
|
|
254
|
+
onMouseLeave: handleMouseLeave,
|
|
255
|
+
onFocus: handleFocus,
|
|
256
|
+
onBlur: handleBlur,
|
|
257
|
+
onClick: handleClick,
|
|
258
|
+
onKeyDown: handleKeyDown,
|
|
259
|
+
},
|
|
260
|
+
tooltipProps: {
|
|
261
|
+
id: tooltipId,
|
|
262
|
+
role: 'tooltip',
|
|
263
|
+
'aria-hidden': !isOpen,
|
|
264
|
+
onMouseEnter: handleTooltipMouseEnter,
|
|
265
|
+
onMouseLeave: handleTooltipMouseLeave,
|
|
266
|
+
},
|
|
267
|
+
isOpen,
|
|
268
|
+
state,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ActionOrLink } from '../utils/component';
|
|
2
2
|
import { Transition } from 'motion';
|
|
3
3
|
import { Icon } from '../icon';
|
|
4
|
+
import { ReactNode } from 'react';
|
|
4
5
|
|
|
5
6
|
export type FabVariant =
|
|
6
7
|
| 'primary'
|
|
@@ -12,7 +13,7 @@ export type FabVariant =
|
|
|
12
13
|
type Props = {
|
|
13
14
|
variant?: FabVariant;
|
|
14
15
|
label?: string;
|
|
15
|
-
children?:
|
|
16
|
+
children?: ReactNode;
|
|
16
17
|
icon: Icon;
|
|
17
18
|
size?: 'small' | 'medium' | 'large';
|
|
18
19
|
extended?: boolean;
|
|
@@ -10,7 +10,10 @@ export type ToolTipInterface<T extends HTMLElement = any> = {
|
|
|
10
10
|
props: {
|
|
11
11
|
variant?: 'plain' | 'rich';
|
|
12
12
|
title?: string;
|
|
13
|
-
text
|
|
13
|
+
/** Supporting text for the tooltip. Optional when using `content` prop. */
|
|
14
|
+
text?: string;
|
|
15
|
+
/** Custom content slot that replaces title/text/buttons when provided */
|
|
16
|
+
content?: ReactNode;
|
|
14
17
|
buttons?: ReactProps<ButtonInterface> | ReactProps<ButtonInterface>[];
|
|
15
18
|
position?:
|
|
16
19
|
| 'top'
|
|
@@ -23,6 +26,18 @@ export type ToolTipInterface<T extends HTMLElement = any> = {
|
|
|
23
26
|
| 'bottom-right';
|
|
24
27
|
trigger?: Trigger | Trigger[];
|
|
25
28
|
transition?: Transition;
|
|
29
|
+
/** Delay in milliseconds before showing the tooltip. Default: 400ms */
|
|
30
|
+
openDelay?: number;
|
|
31
|
+
/** Delay in milliseconds before hiding the tooltip. Default: 150ms */
|
|
32
|
+
closeDelay?: number;
|
|
33
|
+
/** Controlled mode: explicitly control whether the tooltip is open */
|
|
34
|
+
isOpen?: boolean;
|
|
35
|
+
/** Uncontrolled mode: default open state */
|
|
36
|
+
defaultOpen?: boolean;
|
|
37
|
+
/** Callback when the open state changes */
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
/** Custom ID for accessibility linking. Auto-generated if not provided. */
|
|
40
|
+
id?: string;
|
|
26
41
|
} & (
|
|
27
42
|
| {
|
|
28
43
|
children?: never;
|
|
@@ -33,5 +48,12 @@ export type ToolTipInterface<T extends HTMLElement = any> = {
|
|
|
33
48
|
targetRef?: never;
|
|
34
49
|
}
|
|
35
50
|
);
|
|
36
|
-
elements: [
|
|
51
|
+
elements: [
|
|
52
|
+
'toolTip',
|
|
53
|
+
'container',
|
|
54
|
+
'subHead',
|
|
55
|
+
'supportingText',
|
|
56
|
+
'actions',
|
|
57
|
+
'content',
|
|
58
|
+
];
|
|
37
59
|
};
|
|
@@ -34,6 +34,7 @@ const toolTipConfig: ClassNameComponent<ToolTipInterface> = ({
|
|
|
34
34
|
actions: classNames('flex gap-10 px-1 mt-2', variant == 'plain' && 'hidden'),
|
|
35
35
|
subHead: classNames('text-title-small mb-1', variant == 'plain' && 'hidden'),
|
|
36
36
|
supportingText: classNames(''),
|
|
37
|
+
content: classNames('w-full'),
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
export const toolStyle = defaultClassNames<ToolTipInterface>(
|