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 + +
+
+
+ ), +};