Skip to content
Closed
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
2 changes: 1 addition & 1 deletion packages/gamut/src/FocusTrap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface FocusTrapProps extends WithChildrenProp {
/**
* Passthrough for react-focus-on library props
*/
focusOnProps?: ReactFocusOnProps;
focusOnProps?: Partial<Omit<ReactFocusOnProps, 'children'>>;
}

export const FocusTrap: React.FC<FocusTrapProps> = ({
Expand Down
54 changes: 30 additions & 24 deletions packages/gamut/src/Popover/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,36 @@ import { getBeakVariant } from './styles/beak';
import { PopoverProps } from './types';
import { getDefaultOffset } from './utils';

export const Popover: React.FC<PopoverProps> = ({
animation,
align = 'left',
beak,
children,
className,
isOpen,
onRequestClose,
outline = false,
skipFocusTrap,
pattern: Pattern,
popoverContainerRef,
position = 'below',
role,
variant,
targetRef,
horizontalOffset = getDefaultOffset({
axis: 'horizontal',
position,
export const Popover: React.FC<PopoverProps> = (props) => {
const {
animation,
align = 'left',
beak,
children,
className,
isOpen,
onRequestClose,
outline = false,
skipFocusTrap,
pattern: Pattern,
popoverContainerRef,
position = 'below',
role,
variant,
}),
verticalOffset = getDefaultOffset({ axis: 'vertical', position, variant }),

widthRestricted,
}) => {
targetRef,
horizontalOffset = getDefaultOffset({
axis: 'horizontal',
position,
variant,
}),
verticalOffset = getDefaultOffset({ axis: 'vertical', position, variant }),

widthRestricted,
} = props;

// Type guard: focusOnProps is only available when skipFocusTrap is false
const focusOnProps =
'focusOnProps' in props && !skipFocusTrap ? props.focusOnProps : undefined;
const [popoverHeight, setPopoverHeight] = useState<number>(0);
const [popoverWidth, setPopoverWidth] = useState<number>(0);
const [targetRect, setTargetRect] = useState<DOMRect>();
Expand Down Expand Up @@ -240,6 +245,7 @@ export const Popover: React.FC<PopoverProps> = ({
allowPageInteraction
onClickOutside={handleClickOutside}
onEscapeKey={onRequestClose}
{...(focusOnProps ? { focusOnProps } : {})}
>
{contents}
</FocusTrap>
Expand Down
5 changes: 5 additions & 0 deletions packages/gamut/src/Popover/types.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PatternProps } from '@codecademy/gamut-patterns';
import { HTMLAttributes } from 'react';
import { ReactFocusOnProps } from 'react-focus-on/dist/es5/types';

import { PopoverVariants } from './elements';

Expand All @@ -13,6 +14,10 @@ export type FocusTrapPopoverProps = {
* Whether to include the focus trap - should only be skipped if parent of Popover is handling focus managment and accessibility (as is the case with FloatingToolTip). This also disables you from being to specify FocusTrap specific event handlers.
*/
skipFocusTrap?: never;
/**
* Props to pass through to the underlying FocusTrap component's react-focus-on instance.
*/
focusOnProps?: Partial<Omit<ReactFocusOnProps, 'children'>>;
};

export type SkippedFocusTrapPopoverProps = {
Expand Down
83 changes: 67 additions & 16 deletions packages/gamut/src/Tip/InfoTip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,35 +106,83 @@ export const InfoTip: React.FC<InfoTipProps> = ({
}
};

// Helper function to check if an element is within the popover content (not the button)
const isWithinPopoverContent = (element: Node | null): boolean => {
if (!element) return false;
const popoverContent = popoverContentRef.current;
if (!popoverContent) return false;
return popoverContent.contains(element);
};

// Helper function to return focus to button
const returnFocusToButton = () => {
if (isTipHidden) return;
const currentButton = buttonRef.current;
if (
currentButton?.isConnected &&
currentButton instanceof HTMLElement &&
!currentButton.hasAttribute('disabled') &&
currentButton.tabIndex !== -1
) {
currentButton.focus();
}
};

// Handle focusout on the popover content only
// This catches when focus leaves the popover content and returns it to the button
// But allows focus to leave the button freely
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 (!popoverContent || !button || isTipHidden) return;

// If focus is staying within the popover content, allow it
if (popoverContent?.contains(relatedTarget)) return;
// If relatedTarget is null (common with portals or when tabbing to browser UI),
// check activeElement after focus settles
if (!relatedTarget) {
setTimeout(() => {
if (isTipHidden) return;
const { activeElement } = document;
// Only return focus if it left the popover content and didn't go to the button
if (
activeElement &&
activeElement !== button &&
!isWithinPopoverContent(activeElement)
) {
returnFocusToButton();
}
}, 0);
return;
}

// Return focus to button to maintain logical tab order
setTimeout(() => {
buttonRef.current?.focus();
}, 0);
// Type guard: relatedTarget must be a Node to use contains
if (!(relatedTarget instanceof Node)) {
return;
}

// If focus is moving to the button, allow it
if (button.contains(relatedTarget)) {
return;
}

// If focus is staying within the popover content, allow it
if (isWithinPopoverContent(relatedTarget)) {
return;
}

// Focus is leaving the popover content - return to button
// But don't trap it - user can tab away from button freely
returnFocusToButton();
};

// Wait for the popover ref to be set before attaching the listener
// Wait for popover ref to be set before attaching listeners
let popoverContent: HTMLDivElement | null = null;
const timeoutId = setTimeout(() => {
popoverContent = popoverContentRef.current;
if (popoverContent) {
popoverContent.addEventListener('focusout', handleFocusOut);
// Use capture phase to catch focusout events early
popoverContent.addEventListener('focusout', handleFocusOut, true);
}
}, 0);

Expand All @@ -143,7 +191,7 @@ export const InfoTip: React.FC<InfoTipProps> = ({
return () => {
clearTimeout(timeoutId);
if (popoverContent) {
popoverContent.removeEventListener('focusout', handleFocusOut);
popoverContent.removeEventListener('focusout', handleFocusOut, true);
}
document.removeEventListener('keydown', handleGlobalEscapeKey);
};
Expand All @@ -156,9 +204,12 @@ export const InfoTip: React.FC<InfoTipProps> = ({

const tipProps = {
alignment,
buttonRef,
escapeKeyPressHandler,
info,
isTipHidden,
onRequestClose:
placement === 'floating' ? () => setTipIsHidden(true) : undefined,
popoverContentRef,
wrapperRef,
...rest,
Expand Down
11 changes: 10 additions & 1 deletion packages/gamut/src/Tip/shared/FloatingTip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
isTipHidden,
loading,
narrow,
onRequestClose,
overline,
popoverContentRef,
truncateLines,
Expand Down Expand Up @@ -137,6 +138,14 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({

const isPopoverOpen = isHoverType ? isOpen : !isTipHidden;

// When type is 'info', skip focus trap entirely since we're handling focus management ourselves
// This allows focus to leave freely, and custom logic in InfoTip will catch when focus leaves
// and return it to the button
const popoverFocusProps =
type === 'info'
? ({ skipFocusTrap: true, onRequestClose: undefined } as const)
: ({ skipFocusTrap: undefined, onRequestClose } as const);

return (
<Box
display="inline-flex"
Expand All @@ -162,13 +171,13 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
</TargetContainer>
<FloatingTipBody
{...commonPopoverProps}
{...popoverFocusProps}
animation="fade"
dims={dims}
horizontalOffset={offset}
isOpen={isPopoverOpen}
outline
popoverContainerRef={popoverContentRef}
skipFocusTrap
targetRef={ref}
variant="secondary"
widthRestricted={false}
Expand Down
2 changes: 2 additions & 0 deletions packages/gamut/src/Tip/shared/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ export type TipPlacementComponentProps = Omit<
'placement' | 'emphasis'
> & {
alignment: TipStaticAlignment;
buttonRef?: React.RefObject<HTMLButtonElement>;
escapeKeyPressHandler?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
id?: string;
isTipHidden?: boolean;
onRequestClose?: () => void;
popoverContentRef?: React.RefObject<HTMLDivElement>;
type: 'info' | 'tool' | 'preview';
wrapperRef?: React.RefObject<HTMLDivElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@
}
onClick={onClick}
/>
<button ref={ref} style={{ marginLeft: '16px' }}>

Check failure on line 104 in packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

Missing an explicit type attribute for button
I am a button
</button>
</FlexBox>
);
},
Expand Down
Loading