diff --git a/.changeset/modern-dryers-give.md b/.changeset/modern-dryers-give.md new file mode 100644 index 000000000..5cd50d514 --- /dev/null +++ b/.changeset/modern-dryers-give.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix tooltip logic in ItemBase component. diff --git a/.changeset/rude-ducks-share.md b/.changeset/rude-ducks-share.md new file mode 100644 index 000000000..63823324b --- /dev/null +++ b/.changeset/rude-ducks-share.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix accessibility by setting keyboard props to hotkeys in ItemBase component. diff --git a/src/components/content/ItemBase/ItemBase.stories.tsx b/src/components/content/ItemBase/ItemBase.stories.tsx index 338bd24a9..c6d0c3752 100644 --- a/src/components/content/ItemBase/ItemBase.stories.tsx +++ b/src/components/content/ItemBase/ItemBase.stories.tsx @@ -1,8 +1,10 @@ import { IconCoin, IconSettings, IconUser } from '@tabler/icons-react'; +import { useState } from 'react'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { DirectionIcon } from '../../../icons'; import { baseProps } from '../../../stories/lists/baseProps'; +import { Button } from '../../actions'; import { Space } from '../../layout/Space'; import { Title } from '../Title'; @@ -1105,3 +1107,66 @@ WithAutoTooltip.parameters = { }, }, }; + +export const DynamicAutoTooltip: StoryFn = () => { + const [width, setWidth] = useState('400px'); + + return ( +
+ } + style={{ width }} + tooltip={{ delay: 0 }} + > + This is a very long label that will eventually overflow + + +
+ ); +}; + +// DynamicAutoTooltip.play = async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// await timeout(250); + +// const item = await canvas.findByTestId('dynamic-tooltip-item'); + +// // Test 1: No tooltip when wide +// // this is a weird hack that makes tooltip working properly on page load +// await userEvent.unhover(item); +// await userEvent.hover(item); +// await timeout(1000); +// expect(canvas.queryByRole('tooltip')).toBe(null); + +// // Change width to trigger overflow +// await userEvent.unhover(item); +// const resizeButton = await canvas.findByTestId('resize-button'); +// await userEvent.click(resizeButton); +// // Unhover button after clicking to clear any hover state +// await userEvent.unhover(resizeButton); +// await timeout(1000); + +// // Test 2: Tooltip appears when narrow +// // this is a weird hack that makes tooltip working properly +// await userEvent.unhover(item); +// await userEvent.hover(item); + +// await waitFor(() => expect(canvas.getByRole('tooltip')).toBeVisible()); +// }; + +DynamicAutoTooltip.parameters = { + docs: { + description: { + story: + 'Tests the dynamic auto tooltip behavior that responds to width changes. Initially the item is wide and no tooltip appears. When the width is reduced, the label overflows and the tooltip automatically appears on hover.', + }, + }, +}; diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index 07e39d7f0..0fdd0257e 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { OverlayProps } from 'react-aria'; @@ -125,6 +126,10 @@ export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { * @default "top" */ defaultTooltipPlacement?: OverlayProps['placement']; + /** + * Ref to access the label element directly + */ + labelRef?: RefObject; } const DEFAULT_ICON_STYLES: Styles = { @@ -387,6 +392,8 @@ export function useAutoTooltip({ // Track label overflow for auto tooltip (only when enabled) const mergedLabelRef = useCombinedRefs((labelProps as any)?.ref); const [isLabelOverflowed, setIsLabelOverflowed] = useState(false); + const observedElementRef = useRef(null); + const resizeObserverRef = useRef(null); const checkLabelOverflow = useCallback(() => { const label = mergedLabelRef.current; @@ -396,7 +403,6 @@ export function useAutoTooltip({ } const hasOverflow = label.scrollWidth > label.clientWidth; - setIsLabelOverflowed(hasOverflow); }, [mergedLabelRef]); @@ -406,24 +412,64 @@ export function useAutoTooltip({ } }, [isAutoTooltipEnabled, checkLabelOverflow]); - useEffect(() => { - if (!isAutoTooltipEnabled) return; - - const label = mergedLabelRef.current; - if (!label) return; + // Attach ResizeObserver via callback ref to handle DOM node changes + const handleLabelElementRef = useCallback( + (element: HTMLElement | null) => { + // Sync to combined ref so external refs receive the node + (mergedLabelRef as any).current = element; + + // Disconnect previous observer + if (resizeObserverRef.current) { + try { + resizeObserverRef.current.disconnect(); + } catch { + // do nothing + } + resizeObserverRef.current = null; + } - const resizeObserver = new ResizeObserver(checkLabelOverflow); - resizeObserver.observe(label); + observedElementRef.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); + } + }, + [mergedLabelRef, isAutoTooltipEnabled, checkLabelOverflow], + ); - return () => resizeObserver.disconnect(); - }, [isAutoTooltipEnabled, checkLabelOverflow, mergedLabelRef]); + // Cleanup on unmount + useEffect(() => { + return () => { + if (resizeObserverRef.current) { + try { + resizeObserverRef.current.disconnect(); + } catch { + // do nothing + } + resizeObserverRef.current = null; + } + observedElementRef.current = null; + }; + }, []); const finalLabelProps = useMemo(() => { - return { + const props = { ...(labelProps || {}), - ref: mergedLabelRef, - } as Props & { ref?: any }; - }, [labelProps, mergedLabelRef]); + }; + + delete props.ref; + + return props; + }, [labelProps]); const renderWithTooltip = useCallback( ( @@ -498,7 +544,7 @@ export function useAutoTooltip({ ); return { - labelRef: mergedLabelRef, + labelRef: handleLabelElementRef, labelProps: finalLabelProps, isLabelOverflowed, isAutoTooltipEnabled, @@ -574,6 +620,7 @@ const ItemBase = ( suffix ?? (hotkeys ? ( @@ -633,7 +680,7 @@ const ItemBase = ( const { labelProps: finalLabelProps, - hasTooltip, + labelRef, renderWithTooltip, } = useAutoTooltip({ tooltip, children, labelProps }); @@ -660,7 +707,6 @@ const ItemBase = ( return ( ( )} {finalPrefix &&
{finalPrefix}
} {children || labelProps ? ( -
+
{children}
) : null}