-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathEllipsisTooltip.tsx
More file actions
137 lines (123 loc) · 4.64 KB
/
EllipsisTooltip.tsx
File metadata and controls
137 lines (123 loc) · 4.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import type { FC, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DialTooltipContainer } from '@/components/Tooltip/TooltipContainer';
import { DialTooltipContent } from '@/components/Tooltip/TooltipContent';
import type { DialTooltipContainerOptions } from '@/components/Tooltip/TooltipContext';
import { DialTooltipTrigger } from '@/components/Tooltip/TooltipTrigger';
import { tooltipContentBaseClassName } from './constants';
import { mergeClasses } from '@/utils/merge-classes';
export interface DialEllipsisTooltipProps extends DialTooltipContainerOptions {
text: ReactNode;
className?: string;
contentClassName?: string;
hideTooltip?: boolean;
id?: string;
customTooltipContent?: ReactNode;
}
/**
* Single-line text with CSS ellipsis that shows a tooltip **only when actually truncated**.
* If the text fits, tooltip content is empty and the popup stays hidden.
*
* Important: width must be finite for truncation.
* Consumers can override via `className`.
*
* a11y: when truncated, the full text is exposed via `aria-label` on the reference node.
*
* @example
* ```tsx
* <DialEllipsisTooltip text="Very long message that will be truncated" />
* <DialEllipsisTooltip text={<span className="font-medium">Custom node</span>} className="max-w-[160px]" />
* <DialEllipsisTooltip text="Tooltip disabled even if truncated" hideTooltip />
* ```
*
* @param text The text or node to display (truncated with ellipsis if too long).
* @param className Optional additional CSS classes for the text container (e.g. to set width).
* @param contentClassName Optional additional CSS classes for the tooltip content.
* @param hideTooltip If true, disables the tooltip even if text is truncated.
* @param id Optional attribute for unique identification
* @param customTooltipContent If provided, this content will be shown in the tooltip instead of the full text when truncated.
* @param tooltipProps Additional props to pass to the underlying DialTooltipContainer.
*/
export const DialEllipsisTooltip: FC<DialEllipsisTooltipProps> = ({
text,
className,
contentClassName,
hideTooltip,
id,
customTooltipContent,
...tooltipProps
}) => {
const ref = useRef<HTMLElement | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [nodeTextSnapshot, setNodeTextSnapshot] = useState<string>('');
const rafRef = useRef<number | null>(null);
const computeTruncation = () => {
const el = ref.current as HTMLElement | null;
if (!el) return;
setNodeTextSnapshot(el.textContent ?? '');
const client = el.clientWidth;
const scroll = el.scrollWidth;
const rectW = Math.ceil(el.getBoundingClientRect().width);
setIsTruncated(scroll > client || scroll > rectW);
};
const scheduleCompute = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(computeTruncation);
}, []);
useEffect(() => {
scheduleCompute();
const onResize = () => scheduleCompute();
window.addEventListener('resize', onResize);
let ro: ResizeObserver | null = null;
if ('ResizeObserver' in window && ref.current) {
ro = new ResizeObserver(() => scheduleCompute());
ro.observe(ref.current);
}
return () => {
window.removeEventListener('resize', onResize);
if (ro) ro.disconnect();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [text, scheduleCompute]);
const fullText = useMemo(
() => (typeof text === 'string' ? text : nodeTextSnapshot),
[nodeTextSnapshot, text],
);
const tooltipContent = useMemo(() => {
if (hideTooltip) return '';
if (customTooltipContent) return customTooltipContent;
return isTruncated ? fullText : '';
}, [customTooltipContent, fullText, hideTooltip, isTruncated]);
return (
<DialTooltipContainer {...tooltipProps}>
<DialTooltipTrigger
asChild
onMouseEnter={scheduleCompute}
onFocusCapture={scheduleCompute}
>
<span
id={id}
className={mergeClasses(
'block truncate flex-1 min-w-0 max-w-full text-left',
className,
)}
aria-label={isTruncated ? fullText : undefined}
onMouseEnter={scheduleCompute}
onFocus={scheduleCompute}
ref={ref}
>
{text}
</span>
</DialTooltipTrigger>
<DialTooltipContent
className={mergeClasses(
tooltipContentBaseClassName,
contentClassName,
!tooltipContent && 'hidden',
)}
>
{tooltipContent}
</DialTooltipContent>
</DialTooltipContainer>
);
};