Skip to content
Draft
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
94 changes: 80 additions & 14 deletions packages/gamut/src/Tip/InfoTip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

import { FloatingTip } from '../shared/FloatingTip';
import { InlineTip } from '../shared/InlineTip';
Expand Down Expand Up @@ -30,26 +30,31 @@ export const InfoTip: React.FC<InfoTipProps> = ({
const [isTipHidden, setHideTip] = useState(true);
const [isAriaHidden, setIsAriaHidden] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverContentRef = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);

useEffect(() => {
setLoaded(true);
}, []);

const setTipIsHidden = (nextTipState: boolean) => {
if (!nextTipState) {
setHideTip(nextTipState);
if (placement !== 'floating') {
// on inline component - stops text from being able to be navigated through, instead user can nav through visible text
setTimeout(() => {
setIsAriaHidden(true);
}, 1000);
const setTipIsHidden = useCallback(
(nextTipState: boolean) => {
if (!nextTipState) {
setHideTip(nextTipState);
if (placement !== 'floating') {
// on inline component - stops text from being able to be navigated through, instead user can nav through visible text
setTimeout(() => {
setIsAriaHidden(true);
}, 1000);
}
} else {
if (isAriaHidden) setIsAriaHidden(false);
setHideTip(nextTipState);
}
} else {
if (isAriaHidden) setIsAriaHidden(false);
setHideTip(nextTipState);
}
};
},
[isAriaHidden, placement]
);

const escapeKeyPressHandler = (
event: React.KeyboardEvent<HTMLDivElement>
Expand Down Expand Up @@ -84,6 +89,65 @@ export const InfoTip: React.FC<InfoTipProps> = ({
};
});

useEffect(() => {
if (!isTipHidden && placement === 'floating') {
const handleGlobalEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setTipIsHidden(true);
buttonRef.current?.focus();
}
};

const handleFocusOut = (event: FocusEvent) => {
const popoverContent = popoverContentRef.current;
const button = buttonRef.current;
const wrapper = wrapperRef.current;

const { relatedTarget } = event;

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 focus is staying within the popover content, allow it
if (popoverContent?.contains(relatedTarget)) return;

// Focus is leaving the InfoTip system entirely (via Tab, arrow keys, or any navigation)
// Return it to the button to maintain logical focus order
buttonRef.current?.focus();
} else if (relatedTarget === null) {
// Focus is being removed entirely (e.g., clicking elsewhere or navigating)
// Return focus to button to maintain logical tab order
setTimeout(() => {
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);
}
}, 0);

document.addEventListener('keydown', handleGlobalEscapeKey);

return () => {
clearTimeout(timeoutId);
if (popoverContent) {
popoverContent.removeEventListener('focusout', handleFocusOut);
}
document.removeEventListener('keydown', handleGlobalEscapeKey);
};
}
}, [isTipHidden, placement, setTipIsHidden]);

const isFloating = placement === 'floating';

const Tip = loaded && isFloating ? FloatingTip : InlineTip;
Expand All @@ -93,6 +157,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
escapeKeyPressHandler,
info,
isTipHidden,
popoverContentRef,
wrapperRef,
...rest,
};
Expand All @@ -112,6 +177,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
active={!isTipHidden}
aria-expanded={!isTipHidden}
emphasis={emphasis}
ref={buttonRef}
onClick={() => clickHandler()}
/>
);
Expand Down
6 changes: 5 additions & 1 deletion packages/gamut/src/Tip/shared/FloatingTip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
loading,
narrow,
overline,
popoverContentRef,
truncateLines,
type,
username,
Expand Down Expand Up @@ -133,6 +134,8 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
info
);

const isPopoverOpen = isHoverType ? isOpen : !isTipHidden;

return (
<Box
display="inline-flex"
Expand Down Expand Up @@ -161,8 +164,9 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
animation="fade"
dims={dims}
horizontalOffset={offset}
isOpen={isHoverType ? isOpen : !isTipHidden}
isOpen={isPopoverOpen}
outline
popoverContainerRef={popoverContentRef}
skipFocusTrap
targetRef={ref}
variant="secondary"
Expand Down
1 change: 1 addition & 0 deletions packages/gamut/src/Tip/shared/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type TipPlacementComponentProps = Omit<
escapeKeyPressHandler?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
id?: string;
isTipHidden?: boolean;
popoverContentRef?: React.RefObject<HTMLDivElement>;
type: 'info' | 'tool' | 'preview';
wrapperRef?: React.RefObject<HTMLDivElement>;
zIndex?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const WithLinksOrButtonsExample: React.FC<InfoTipProps> = ({ args }) => {

return (
<FlexBox center py={64}>
<Text mr={4}>This text is in a small space and needs info </Text>
<InfoTip info="I am cool" placement="floating" {...args} />
<Text mr={4}>This text is in a small space and needs info </Text>{' '}
<InfoTip
info={
Expand Down
Loading