diff --git a/.changeset/fifty-bats-smash.md b/.changeset/fifty-bats-smash.md
new file mode 100644
index 000000000..36be412c7
--- /dev/null
+++ b/.changeset/fifty-bats-smash.md
@@ -0,0 +1,5 @@
+---
+"@launchpad-ui/components": minor
+---
+
+Updated the Text component to include an optional tooltip on overflow
diff --git a/packages/components/__tests__/Text.spec.tsx b/packages/components/__tests__/Text.spec.tsx
new file mode 100644
index 000000000..5f5b558a0
--- /dev/null
+++ b/packages/components/__tests__/Text.spec.tsx
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+
+import { render, screen } from '../../../test/utils';
+import { Text } from '../src';
+
+describe('Text', () => {
+ it('renders', () => {
+ render(Hello world);
+ expect(screen.getByText('Hello world')).toBeVisible();
+ });
+
+ it('renders with different sizes', () => {
+ const { rerender } = render(Small text);
+ expect(screen.getByText('Small text')).toBeVisible();
+
+ rerender(Medium text);
+ expect(screen.getByText('Medium text')).toBeVisible();
+
+ rerender(Large text);
+ expect(screen.getByText('Large text')).toBeVisible();
+ });
+
+ it('renders with bold prop', () => {
+ render(Bold text);
+ expect(screen.getByText('Bold text')).toBeVisible();
+ });
+
+ it('renders with maxLines', () => {
+ render(Long text that should be truncated);
+ expect(screen.getByText('Long text that should be truncated')).toBeVisible();
+ });
+
+ it('renders with overflow tooltip when enabled', () => {
+ render(
+
+ Text with overflow tooltip
+ ,
+ );
+ expect(screen.getByText('Text with overflow tooltip')).toBeVisible();
+ });
+
+ it('renders with custom tooltip content', () => {
+ render(
+
+ Truncated text
+ ,
+ );
+ expect(screen.getByText('Truncated text')).toBeVisible();
+ });
+});
diff --git a/packages/components/src/Text.tsx b/packages/components/src/Text.tsx
index 7348013de..97a4a1cae 100644
--- a/packages/components/src/Text.tsx
+++ b/packages/components/src/Text.tsx
@@ -1,11 +1,15 @@
-import type { Ref } from 'react';
+import type { ReactNode, Ref } from 'react';
import type { TextProps as AriaTextProps, ContextValue } from 'react-aria-components';
+import type { TooltipProps } from './Tooltip';
import { cva, cx } from 'class-variance-authority';
-import { createContext } from 'react';
+import { createContext, useCallback, useEffect, useRef, useState } from 'react';
+import { useFocus, useFocusVisible, useHover } from 'react-aria';
import { Text as AriaText } from 'react-aria-components';
+import { Focusable } from './Focusable';
import styles from './styles/Text.module.css';
+import { Tooltip, TooltipTrigger } from './Tooltip';
import { useLPContextProps } from './utils';
const textStyles = cva(styles.text, {
@@ -37,6 +41,14 @@ interface TextProps extends Omit {
elementType?: AriaTextProps['elementType'];
/** Optional CSS class name */
className?: AriaTextProps['className'];
+ /** Enable tooltip on text overflow */
+ showTooltipOnOverflow?: boolean;
+ /** Custom tooltip content. If not provided, uses the text children as tooltip content */
+ tooltipContent?: ReactNode;
+ /** Tooltip placement */
+ tooltipPlacement?: TooltipProps['placement'];
+ /** Additional CSS class name for the tooltip */
+ tooltipClassName?: string;
}
const TextContext = createContext>(null);
@@ -48,6 +60,38 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
return 'span';
};
+/**
+ * Custom hook to detect text overflow
+ */
+const useTextOverflow = () => {
+ const ref = useRef(null);
+ const [hasOverflow, setHasOverflow] = useState(false);
+
+ const checkOverflow = useCallback(() => {
+ if (!ref.current) return;
+
+ const element = ref.current;
+ const isOverflowing =
+ element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
+
+ setHasOverflow(isOverflowing);
+ }, []);
+
+ useEffect(() => {
+ checkOverflow();
+
+ // Recheck on window resize
+ const resizeObserver = new ResizeObserver(checkOverflow);
+ if (ref.current) {
+ resizeObserver.observe(ref.current);
+ }
+
+ return () => resizeObserver.disconnect();
+ }, [checkOverflow]);
+
+ return { ref, hasOverflow };
+};
+
/**
* A generic Text component for body text.
*
@@ -56,21 +100,49 @@ const getDefaultElementType = (size: 'small' | 'medium' | 'large'): string => {
* Built on top of [React Aria `Text` component](https://react-spectrum.adobe.com/react-spectrum/Text.html#text).
*/
const Text = ({
- ref,
+ ref: externalRef,
size = 'medium',
bold = false,
maxLines,
elementType,
className,
style,
+ showTooltipOnOverflow = false,
+ tooltipContent,
+ tooltipPlacement = 'top',
+ tooltipClassName,
...props
}: TextProps) => {
- [props, ref] = useLPContextProps(props, ref, TextContext);
+ [props, externalRef] = useLPContextProps(props, externalRef, TextContext);
- return (
+ const { ref: overflowRef, hasOverflow } = useTextOverflow();
+ const { hoverProps, isHovered } = useHover({});
+ const [isFocused, setFocused] = useState(false);
+ const { focusProps } = useFocus({
+ onFocus: () => setFocused(true),
+ onBlur: () => setFocused(false),
+ });
+ const { isFocusVisible } = useFocusVisible();
+
+ // Merge refs
+ const mergedRef = useCallback(
+ (element: HTMLElement | null) => {
+ overflowRef.current = element;
+
+ if (typeof externalRef === 'function') {
+ externalRef(element);
+ } else if (externalRef && 'current' in externalRef) {
+ externalRef.current = element;
+ }
+ },
+ [externalRef, overflowRef],
+ );
+
+ const textElement = (
);
+
+ if (!showTooltipOnOverflow) {
+ return textElement;
+ }
+
+ return (
+
+ {textElement}
+
+ {tooltipContent ?? props.children}
+
+
+ );
};
export { Text, TextContext, textStyles };
diff --git a/packages/components/stories/Text.stories.tsx b/packages/components/stories/Text.stories.tsx
index a911a4cc4..e0e677269 100644
--- a/packages/components/stories/Text.stories.tsx
+++ b/packages/components/stories/Text.stories.tsx
@@ -37,6 +37,23 @@ For headings, use [Heading](/docs/components-content-heading--docs). For labels,
maxLines: {
control: { type: 'number' },
},
+ showTooltipOnOverflow: {
+ control: { type: 'boolean' },
+ description: 'Show tooltip when text overflows',
+ },
+ tooltipContent: {
+ control: { type: 'text' },
+ description: 'Custom tooltip content (defaults to text children)',
+ },
+ tooltipPlacement: {
+ control: { type: 'select' },
+ options: ['top', 'bottom', 'left', 'right', 'start', 'end'],
+ description: 'Tooltip placement',
+ },
+ tooltipClassName: {
+ control: { type: 'text' },
+ description: 'Additional CSS class for tooltip',
+ },
elementType: {
table: {
disable: true,
@@ -124,3 +141,87 @@ export const Truncation: Story = {
),
};
+
+/**
+ * Show a tooltip when text overflows by enabling the `showTooltipOnOverflow` prop.
+ * The tooltip only appears when the text is actually truncated.
+ */
+export const OverflowTooltip: Story = {
+ render: () => (
+
+
+
+ Single line with overflow tooltip:
+
+
+ This is a very long text that will be truncated and show a tooltip on hover or focus
+
+
+
+
+
+ Multi-line (2 lines) with overflow tooltip:
+
+
+ This is a longer text that will be truncated after two lines and show a tooltip when you
+ hover over it. The tooltip contains the full text content.
+
+
+
+
+
+ No overflow (tooltip won't show):
+
+
+ Short text
+
+
+
+ ),
+};
+
+/**
+ * Customize the tooltip content and placement.
+ */
+export const CustomTooltip: Story = {
+ render: () => (
+
+
+
+ Custom tooltip content:
+
+
+ This is the truncated text that appears in the component
+
+
+
+
+
Bottom placement:
+
+ Hover to see tooltip below this text. This text is long enough to be truncated.
+
+
+
+
+
+ Different sizes with tooltips:
+
+
+
+ Small text with overflow tooltip - this text will be truncated
+
+
+ Medium text with overflow tooltip - this text will be truncated
+
+
+ Large text with overflow tooltip - this text will be truncated
+
+