Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/add-text-item-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cube-dev/ui-kit": minor
---

Added `TextItem` component for displaying text with automatic overflow handling and tooltips. Features include:
- Auto-tooltip on text overflow (enabled by default)
- Text highlighting with `highlight` prop for search results
- Customizable highlight styles via `highlightStyles` prop
- Case-sensitive/insensitive highlight matching
- Inherits all `Text` component props

Added `Text.Highlight` sub-component for semantic text highlighting (uses `<mark>` element).

**Breaking:** Removed `Text.Selection` in favor of `Text.Highlight`.

211 changes: 2 additions & 209 deletions src/components/content/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ import {
PointerEvent,
ReactNode,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { OverlayProps } from 'react-aria';
import { useHotkeys } from 'react-hotkeys-hook';
Expand Down Expand Up @@ -60,12 +56,10 @@ import {
import { mergeProps } from '../../../utils/react';
import { ItemAction } from '../../actions/ItemAction';
import { ItemActionProvider } from '../../actions/ItemActionContext';
import {
CubeTooltipProviderProps,
TooltipProvider,
} from '../../overlays/Tooltip/TooltipProvider';
import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider';
import { HotKeys } from '../HotKeys';
import { ItemBadge } from '../ItemBadge';
import { useAutoTooltip } from '../use-auto-tooltip';

export interface CubeItemProps extends BaseProps, ContainerStyleProps {
icon?: ReactNode | 'checkbox';
Expand Down Expand Up @@ -454,207 +448,6 @@ const ItemElement = tasty({
styleProps: CONTAINER_STYLES,
});

export function useAutoTooltip({
tooltip,
children,
labelProps,
isDynamicLabel = false, // if actions are set
}: {
tooltip: CubeItemProps['tooltip'];
children: ReactNode;
labelProps?: Props;
isDynamicLabel?: boolean;
}) {
// Determine if auto tooltip is enabled
// Auto tooltip only works when children is a string (overflow detection needs text)
const isAutoTooltipEnabled = useMemo(() => {
if (typeof children !== 'string') return false;

// Boolean true enables auto overflow detection
if (tooltip === true) return true;
if (typeof tooltip === 'object') {
// If title is provided and auto is explicitly true, enable auto overflow detection
if (tooltip.title) {
return tooltip.auto === true;
}

// If no title is provided, default to auto=true unless explicitly disabled
const autoValue = tooltip.auto !== undefined ? tooltip.auto : true;
return !!autoValue;
}
return false;
}, [tooltip, children]);

// Track label overflow for auto tooltip (only when enabled)
const externalLabelRef = (labelProps as any)?.ref;
const [isLabelOverflowed, setIsLabelOverflowed] = useState(false);
const elementRef = useRef<HTMLElement | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);

const checkLabelOverflow = useCallback(() => {
const label = elementRef.current;
if (!label) {
setIsLabelOverflowed(false);
return;
}

const hasOverflow = label.scrollWidth > label.clientWidth;
setIsLabelOverflowed(hasOverflow);
}, []);

useEffect(() => {
if (isAutoTooltipEnabled) {
checkLabelOverflow();
}
}, [isAutoTooltipEnabled, checkLabelOverflow]);

// Attach ResizeObserver via callback ref to handle DOM node changes
const handleLabelElementRef = useCallback(
(element: HTMLElement | null) => {
// Call external callback ref to notify external refs
if (externalLabelRef) {
if (typeof externalLabelRef === 'function') {
externalLabelRef(element);
} else {
(externalLabelRef as any).current = element;
}
}

// Disconnect previous observer
if (resizeObserverRef.current) {
try {
resizeObserverRef.current.disconnect();
} catch {
// do nothing
}
resizeObserverRef.current = null;
}

elementRef.current = element;

if (element && isAutoTooltipEnabled) {
// Create a fresh observer to capture the latest callback
const obs = new ResizeObserver(() => {
checkLabelOverflow();
});
resizeObserverRef.current = obs;
obs.observe(element);
// Initial check
checkLabelOverflow();
} else {
setIsLabelOverflowed(false);
}
},
[externalLabelRef, isAutoTooltipEnabled, checkLabelOverflow],
);

// Cleanup on unmount
useEffect(() => {
return () => {
if (resizeObserverRef.current) {
try {
resizeObserverRef.current.disconnect();
} catch {
// do nothing
}
resizeObserverRef.current = null;
}
elementRef.current = null;
};
}, []);

const finalLabelProps = useMemo(() => {
const props = {
...(labelProps || {}),
};

delete props.ref;

return props;
}, [labelProps]);

const renderWithTooltip = (
renderElement: (
tooltipTriggerProps?: HTMLAttributes<HTMLElement>,
tooltipRef?: RefObject<HTMLElement>,
) => ReactNode,
defaultTooltipPlacement: OverlayProps['placement'],
) => {
// Handle tooltip rendering based on tooltip prop type
if (tooltip) {
// String tooltip - simple case
if (typeof tooltip === 'string') {
return (
<TooltipProvider placement={defaultTooltipPlacement} title={tooltip}>
{(triggerProps, ref) => renderElement(triggerProps, ref)}
</TooltipProvider>
);
}

// Boolean tooltip - auto tooltip on overflow
if (tooltip === true) {
if ((children || labelProps) && (isLabelOverflowed || isDynamicLabel)) {
return (
<TooltipProvider
placement={defaultTooltipPlacement}
title={children}
isDisabled={!isLabelOverflowed && isDynamicLabel}
>
{(triggerProps, ref) => renderElement(triggerProps, ref)}
</TooltipProvider>
);
}
}

// Object tooltip - advanced configuration
if (typeof tooltip === 'object') {
const { auto, ...tooltipProps } = tooltip;

// If title is provided and auto is not explicitly true, always show the tooltip
if (tooltipProps.title && auto !== true) {
return (
<TooltipProvider
placement={defaultTooltipPlacement}
{...tooltipProps}
>
{(triggerProps, ref) => renderElement(triggerProps, ref)}
</TooltipProvider>
);
}

// If title is provided with auto=true, OR no title but auto behavior enabled
if ((children || labelProps) && (isLabelOverflowed || isDynamicLabel)) {
return (
<TooltipProvider
placement={defaultTooltipPlacement}
title={tooltipProps.title ?? children}
isDisabled={
!isLabelOverflowed &&
isDynamicLabel &&
tooltipProps.isDisabled !== true
}
{...tooltipProps}
>
{(triggerProps, ref) => renderElement(triggerProps, ref)}
</TooltipProvider>
);
}
}
}

return renderElement();
};

return {
labelRef: handleLabelElementRef,
labelProps: finalLabelProps,
isLabelOverflowed,
isAutoTooltipEnabled,
hasTooltip: !!tooltip,
renderWithTooltip,
};
}

const Item = <T extends HTMLElement = HTMLDivElement>(
props: CubeItemProps,
ref: ForwardedRef<T>,
Expand Down
15 changes: 8 additions & 7 deletions src/components/content/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,17 @@ const EmphasisText = tasty(Text, {
preset: 'em',
});

const SelectionText = tasty(Text, {
const PlaceholderText = tasty(Text, {
styles: {
color: '#dark',
fill: '#note.30',
color: '#current.5',
},
});

const PlaceholderText = tasty(Text, {
const HighlightText = tasty(Text, {
as: 'mark',
styles: {
color: '#current.5',
fill: '#dark.15',
color: '#dark',
},
});

Expand All @@ -155,8 +156,8 @@ export interface TextComponent
Success: typeof SuccessText;
Strong: typeof StrongText;
Emphasis: typeof EmphasisText;
Selection: typeof SelectionText;
Placeholder: typeof PlaceholderText;
Highlight: typeof HighlightText;
}

const _Text: TextComponent = Object.assign(Text, {
Expand All @@ -165,8 +166,8 @@ const _Text: TextComponent = Object.assign(Text, {
Success: SuccessText,
Strong: StrongText,
Emphasis: EmphasisText,
Selection: SelectionText,
Placeholder: PlaceholderText,
Highlight: HighlightText,
});

_Text.displayName = 'Text';
Expand Down
Loading
Loading