Skip to content

Commit 9d5e382

Browse files
committed
feat: implementing an overflow tooltip for the Text component
1 parent 8b925e9 commit 9d5e382

File tree

3 files changed

+248
-6
lines changed

3 files changed

+248
-6
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { render, screen } from '../../../test/utils';
4+
import { Text } from '../src';
5+
6+
describe('Text', () => {
7+
it('renders', () => {
8+
render(<Text>Hello world</Text>);
9+
expect(screen.getByText('Hello world')).toBeVisible();
10+
});
11+
12+
it('renders with different sizes', () => {
13+
const { rerender } = render(<Text size="small">Small text</Text>);
14+
expect(screen.getByText('Small text')).toBeVisible();
15+
16+
rerender(<Text size="medium">Medium text</Text>);
17+
expect(screen.getByText('Medium text')).toBeVisible();
18+
19+
rerender(<Text size="large">Large text</Text>);
20+
expect(screen.getByText('Large text')).toBeVisible();
21+
});
22+
23+
it('renders with bold prop', () => {
24+
render(<Text bold>Bold text</Text>);
25+
expect(screen.getByText('Bold text')).toBeVisible();
26+
});
27+
28+
it('renders with maxLines', () => {
29+
render(<Text maxLines={2}>Long text that should be truncated</Text>);
30+
expect(screen.getByText('Long text that should be truncated')).toBeVisible();
31+
});
32+
33+
it('renders with overflow tooltip when enabled', () => {
34+
render(
35+
<Text maxLines={1} showTooltipOnOverflow>
36+
Text with overflow tooltip
37+
</Text>,
38+
);
39+
expect(screen.getByText('Text with overflow tooltip')).toBeVisible();
40+
});
41+
42+
it('renders with custom tooltip content', () => {
43+
render(
44+
<Text maxLines={1} showTooltipOnOverflow tooltipContent="Custom tooltip">
45+
Truncated text
46+
</Text>,
47+
);
48+
expect(screen.getByText('Truncated text')).toBeVisible();
49+
});
50+
});
51+

packages/components/src/Text.tsx

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import type { Ref } from 'react';
1+
import type { ReactNode, Ref } from 'react';
22
import type { TextProps as AriaTextProps, ContextValue } from 'react-aria-components';
33

44
import { cva, cx } from 'class-variance-authority';
5-
import { createContext } from 'react';
5+
import { createContext, useCallback, useEffect, useRef, useState } from 'react';
6+
import { useFocus, useFocusVisible, useHover } from 'react-aria';
67
import { Text as AriaText } from 'react-aria-components';
78

9+
import { Focusable } from './Focusable';
810
import styles from './styles/Text.module.css';
11+
import type { TooltipProps } from './Tooltip';
12+
import { Tooltip, TooltipTrigger } from './Tooltip';
913
import { useLPContextProps } from './utils';
1014

1115
const textStyles = cva(styles.text, {
@@ -37,6 +41,14 @@ interface TextProps extends Omit<AriaTextProps, 'className' | 'elementType'> {
3741
elementType?: AriaTextProps['elementType'];
3842
/** Optional CSS class name */
3943
className?: AriaTextProps['className'];
44+
/** Enable tooltip on text overflow */
45+
showTooltipOnOverflow?: boolean;
46+
/** Custom tooltip content. If not provided, uses the text children as tooltip content */
47+
tooltipContent?: ReactNode;
48+
/** Tooltip placement */
49+
tooltipPlacement?: TooltipProps['placement'];
50+
/** Additional CSS class name for the tooltip */
51+
tooltipClassName?: string;
4052
}
4153

4254
const TextContext = createContext<ContextValue<TextProps, HTMLElement>>(null);
@@ -48,6 +60,38 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
4860
return 'span';
4961
};
5062

63+
/**
64+
* Custom hook to detect text overflow
65+
*/
66+
const useTextOverflow = () => {
67+
const ref = useRef<HTMLElement>(null);
68+
const [hasOverflow, setHasOverflow] = useState(false);
69+
70+
const checkOverflow = useCallback(() => {
71+
if (!ref.current) return;
72+
73+
const element = ref.current;
74+
const isOverflowing =
75+
element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
76+
77+
setHasOverflow(isOverflowing);
78+
}, []);
79+
80+
useEffect(() => {
81+
checkOverflow();
82+
83+
// Recheck on window resize
84+
const resizeObserver = new ResizeObserver(checkOverflow);
85+
if (ref.current) {
86+
resizeObserver.observe(ref.current);
87+
}
88+
89+
return () => resizeObserver.disconnect();
90+
}, [checkOverflow]);
91+
92+
return { ref, hasOverflow };
93+
};
94+
5195
/**
5296
* A generic Text component for body text.
5397
*
@@ -56,21 +100,49 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
56100
* Built on top of [React Aria `Text` component](https://react-spectrum.adobe.com/react-spectrum/Text.html#text).
57101
*/
58102
const Text = ({
59-
ref,
103+
ref: externalRef,
60104
size = 'medium',
61105
bold = false,
62106
maxLines,
63107
elementType,
64108
className,
65109
style,
110+
showTooltipOnOverflow = false,
111+
tooltipContent,
112+
tooltipPlacement = 'top',
113+
tooltipClassName,
66114
...props
67115
}: TextProps) => {
68-
[props, ref] = useLPContextProps(props, ref, TextContext);
116+
[props, externalRef] = useLPContextProps(props, externalRef, TextContext);
69117

70-
return (
118+
const { ref: overflowRef, hasOverflow } = useTextOverflow();
119+
const { hoverProps, isHovered } = useHover({});
120+
const [isFocused, setFocused] = useState(false);
121+
const { focusProps } = useFocus({
122+
onFocus: () => setFocused(true),
123+
onBlur: () => setFocused(false),
124+
});
125+
const { isFocusVisible } = useFocusVisible();
126+
127+
// Merge refs
128+
const mergedRef = useCallback(
129+
(element: HTMLElement | null) => {
130+
overflowRef.current = element;
131+
132+
if (typeof externalRef === 'function') {
133+
externalRef(element);
134+
} else if (externalRef && 'current' in externalRef) {
135+
externalRef.current = element;
136+
}
137+
},
138+
[externalRef, overflowRef],
139+
);
140+
141+
const textElement = (
71142
<AriaText
72143
{...props}
73-
ref={ref}
144+
{...(showTooltipOnOverflow ? { ...hoverProps, ...focusProps } : {})}
145+
ref={mergedRef}
74146
elementType={elementType || getDefaultElementType(size)}
75147
className={cx(textStyles({ size, bold }), maxLines && styles.truncate, className)}
76148
style={{
@@ -83,6 +155,22 @@ const Text = ({
83155
{props.children}
84156
</AriaText>
85157
);
158+
159+
if (!showTooltipOnOverflow) {
160+
return textElement;
161+
}
162+
163+
return (
164+
<TooltipTrigger
165+
isDisabled={!hasOverflow}
166+
isOpen={hasOverflow && (isHovered || (isFocusVisible && isFocused))}
167+
>
168+
<Focusable>{textElement}</Focusable>
169+
<Tooltip placement={tooltipPlacement} className={tooltipClassName}>
170+
{tooltipContent ?? props.children}
171+
</Tooltip>
172+
</TooltipTrigger>
173+
);
86174
};
87175

88176
export { Text, TextContext, textStyles };

packages/components/stories/Text.stories.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ For headings, use [Heading](/docs/components-content-heading--docs). For labels,
3737
maxLines: {
3838
control: { type: 'number' },
3939
},
40+
showTooltipOnOverflow: {
41+
control: { type: 'boolean' },
42+
description: 'Show tooltip when text overflows',
43+
},
44+
tooltipContent: {
45+
control: { type: 'text' },
46+
description: 'Custom tooltip content (defaults to text children)',
47+
},
48+
tooltipPlacement: {
49+
control: { type: 'select' },
50+
options: ['top', 'bottom', 'left', 'right', 'start', 'end'],
51+
description: 'Tooltip placement',
52+
},
53+
tooltipClassName: {
54+
control: { type: 'text' },
55+
description: 'Additional CSS class for tooltip',
56+
},
4057
elementType: {
4158
table: {
4259
disable: true,
@@ -124,3 +141,89 @@ export const Truncation: Story = {
124141
</div>
125142
),
126143
};
144+
145+
/**
146+
* Show a tooltip when text overflows by enabling the `showTooltipOnOverflow` prop.
147+
* The tooltip only appears when the text is actually truncated.
148+
*/
149+
export const OverflowTooltip: Story = {
150+
render: () => (
151+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem', width: '250px' }}>
152+
<div>
153+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
154+
Single line with overflow tooltip:
155+
</p>
156+
<Text maxLines={1} showTooltipOnOverflow>
157+
This is a very long text that will be truncated and show a tooltip on hover or focus
158+
</Text>
159+
</div>
160+
161+
<div>
162+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
163+
Multi-line (2 lines) with overflow tooltip:
164+
</p>
165+
<Text maxLines={2} showTooltipOnOverflow>
166+
This is a longer text that will be truncated after two lines and show a tooltip when you
167+
hover over it. The tooltip contains the full text content.
168+
</Text>
169+
</div>
170+
171+
<div>
172+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
173+
No overflow (tooltip won't show):
174+
</p>
175+
<Text maxLines={2} showTooltipOnOverflow>
176+
Short text
177+
</Text>
178+
</div>
179+
</div>
180+
),
181+
};
182+
183+
/**
184+
* Customize the tooltip content and placement.
185+
*/
186+
export const CustomTooltip: Story = {
187+
render: () => (
188+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem', width: '250px' }}>
189+
<div>
190+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
191+
Custom tooltip content:
192+
</p>
193+
<Text
194+
maxLines={1}
195+
showTooltipOnOverflow
196+
tooltipContent="This is custom tooltip text that's different from the truncated content"
197+
>
198+
This is the truncated text that appears in the component
199+
</Text>
200+
</div>
201+
202+
<div>
203+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
204+
Bottom placement:
205+
</p>
206+
<Text maxLines={1} showTooltipOnOverflow tooltipPlacement="bottom">
207+
Hover to see tooltip below this text. This text is long enough to be truncated.
208+
</Text>
209+
</div>
210+
211+
<div>
212+
<p style={{ marginBottom: '0.5rem', fontSize: '14px', color: '#666' }}>
213+
Different sizes with tooltips:
214+
</p>
215+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
216+
<Text size="small" maxLines={1} showTooltipOnOverflow>
217+
Small text with overflow tooltip - this text will be truncated
218+
</Text>
219+
<Text size="medium" maxLines={1} showTooltipOnOverflow>
220+
Medium text with overflow tooltip - this text will be truncated
221+
</Text>
222+
<Text size="large" maxLines={1} showTooltipOnOverflow>
223+
Large text with overflow tooltip - this text will be truncated
224+
</Text>
225+
</div>
226+
</div>
227+
</div>
228+
),
229+
};

0 commit comments

Comments
 (0)