Skip to content
110 changes: 79 additions & 31 deletions packages/gamut/src/Tip/InfoTip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import { FloatingTip } from '../shared/FloatingTip';
import { InlineTip } from '../shared/InlineTip';
Expand All @@ -19,6 +27,30 @@ export type InfoTipProps = TipBaseProps & {
onClick?: (arg0: { isTipHidden: boolean }) => void;
};

// Helper function to recursively extract text content from React elements
// Converts everything to plain text for screenreader announcements
const extractTextContent = (children: React.ReactNode): string => {
if (typeof children === 'string' || typeof children === 'number') {
return String(children);
}

return Children.toArray(children)
.map((child) => {
if (typeof child === 'string' || typeof child === 'number') {
return String(child);
}
if (typeof child === 'boolean' || child == null) {
return '';
}
if (isValidElement(child)) {
return extractTextContent(child.props.children);
}
return '';
})
.filter(Boolean)
.join(' ');
};

export const InfoTip: React.FC<InfoTipProps> = ({
alignment = 'top-right',
emphasis = 'low',
Expand Down Expand Up @@ -66,16 +98,19 @@ export const InfoTip: React.FC<InfoTipProps> = ({
}
};

const handleOutsideClick = (e: MouseEvent) => {
if (
wrapperRef.current &&
(e.target instanceof HTMLElement
? !wrapperRef.current?.contains(e?.target)
: true)
) {
setTipIsHidden(true);
}
};
const handleOutsideClick = useCallback(
(e: MouseEvent) => {
if (
wrapperRef.current &&
(e.target instanceof HTMLElement
? !wrapperRef.current?.contains(e?.target)
: true)
) {
setTipIsHidden(true);
}
},
[setTipIsHidden]
);

const clickHandler = () => {
const currentTipState = !isTipHidden;
Expand All @@ -87,15 +122,19 @@ export const InfoTip: React.FC<InfoTipProps> = ({
}, 0);
}
// we want to call the onClick handler after the tip has mounted
if (onClick) setTimeout(() => onClick({ isTipHidden: currentTipState }), 0);
// For floating placement, wait a bit longer to ensure refs are set
if (onClick) {
const delay = placement === 'floating' ? 10 : 0;
setTimeout(() => onClick({ isTipHidden: currentTipState }), delay);
}
};

useEffect(() => {
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
});
}, [handleOutsideClick]);

useEffect(() => {
if (!isTipHidden && placement === 'floating') {
Expand All @@ -106,35 +145,39 @@ export const InfoTip: React.FC<InfoTipProps> = ({
}
};

const handleFocusOut = (event: FocusEvent) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;

const popoverContent = popoverContentRef.current;
const button = buttonRef.current;
const wrapper = wrapperRef.current;
if (!popoverContent) return;

const { relatedTarget } = event;
const focusableElements = popoverContent.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);

if (relatedTarget instanceof Node) {
// If focus is moving back to the button or wrapper, allow it
const movingToButton =
button?.contains(relatedTarget) || wrapper?.contains(relatedTarget);
if (movingToButton) return;
if (focusableElements.length === 0) return;

// If focus is staying within the popover content, allow it
if (popoverContent?.contains(relatedTarget)) return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const { activeElement } = document;

// Return focus to button to maintain logical tab order
setTimeout(() => {
const isTabbingForwardFromLast =
!event.shiftKey && activeElement === lastElement;
const isTabbingBackwardFromFirst =
event.shiftKey && activeElement === firstElement;

if (isTabbingForwardFromLast || isTabbingBackwardFromFirst) {
event.preventDefault();
buttonRef.current?.focus();
}, 0);
}
};

// Wait for the popover ref to be set before attaching the listener
let popoverContent: HTMLDivElement | null = null;
const timeoutId = setTimeout(() => {
popoverContent = popoverContentRef.current;
if (popoverContent) {
popoverContent.addEventListener('focusout', handleFocusOut);
popoverContent.addEventListener('keydown', handleKeyDown);
}
}, 0);

Expand All @@ -143,7 +186,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
return () => {
clearTimeout(timeoutId);
if (popoverContent) {
popoverContent.removeEventListener('focusout', handleFocusOut);
popoverContent.removeEventListener('keydown', handleKeyDown);
}
document.removeEventListener('keydown', handleGlobalEscapeKey);
};
Expand All @@ -164,13 +207,18 @@ export const InfoTip: React.FC<InfoTipProps> = ({
...rest,
};

const extractedTextContent = useMemo(() => extractTextContent(info), [info]);

const screenreaderInfo =
shouldAnnounce && !isTipHidden ? extractedTextContent : `\xa0`;

const text = (
<ScreenreaderNavigableText
aria-hidden={isAriaHidden}
aria-live="assertive"
screenreader
>
{shouldAnnounce && !isTipHidden ? info : `\xa0`}
{screenreaderInfo}
</ScreenreaderNavigableText>
);

Expand Down
Loading