diff --git a/packages/gamut/src/FocusTrap/index.tsx b/packages/gamut/src/FocusTrap/index.tsx index ad8f7cb635c..03326cb30c6 100644 --- a/packages/gamut/src/FocusTrap/index.tsx +++ b/packages/gamut/src/FocusTrap/index.tsx @@ -35,7 +35,7 @@ export interface FocusTrapProps extends WithChildrenProp { /** * Passthrough for react-focus-on library props */ - focusOnProps?: ReactFocusOnProps; + focusOnProps?: Partial>; } export const FocusTrap: React.FC = ({ diff --git a/packages/gamut/src/Popover/Popover.tsx b/packages/gamut/src/Popover/Popover.tsx index 9f3e4aba728..c9ea59a5c72 100755 --- a/packages/gamut/src/Popover/Popover.tsx +++ b/packages/gamut/src/Popover/Popover.tsx @@ -18,31 +18,36 @@ import { getBeakVariant } from './styles/beak'; import { PopoverProps } from './types'; import { getDefaultOffset } from './utils'; -export const Popover: React.FC = ({ - 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 = (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(0); const [popoverWidth, setPopoverWidth] = useState(0); const [targetRect, setTargetRect] = useState(); @@ -240,6 +245,7 @@ export const Popover: React.FC = ({ allowPageInteraction onClickOutside={handleClickOutside} onEscapeKey={onRequestClose} + {...(focusOnProps ? { focusOnProps } : {})} > {contents} diff --git a/packages/gamut/src/Popover/types.tsx b/packages/gamut/src/Popover/types.tsx index b85d4d4595c..a0d8a2f801b 100755 --- a/packages/gamut/src/Popover/types.tsx +++ b/packages/gamut/src/Popover/types.tsx @@ -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'; @@ -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>; }; export type SkippedFocusTrapPopoverProps = { diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 9a39bae3fc6..b8864ef1bca 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -106,35 +106,83 @@ export const InfoTip: React.FC = ({ } }; + // 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); @@ -143,7 +191,7 @@ export const InfoTip: React.FC = ({ return () => { clearTimeout(timeoutId); if (popoverContent) { - popoverContent.removeEventListener('focusout', handleFocusOut); + popoverContent.removeEventListener('focusout', handleFocusOut, true); } document.removeEventListener('keydown', handleGlobalEscapeKey); }; @@ -156,9 +204,12 @@ export const InfoTip: React.FC = ({ const tipProps = { alignment, + buttonRef, escapeKeyPressHandler, info, isTipHidden, + onRequestClose: + placement === 'floating' ? () => setTipIsHidden(true) : undefined, popoverContentRef, wrapperRef, ...rest, diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index c251d1570cf..91a4ced6d0d 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -30,6 +30,7 @@ export const FloatingTip: React.FC = ({ isTipHidden, loading, narrow, + onRequestClose, overline, popoverContentRef, truncateLines, @@ -137,6 +138,14 @@ export const FloatingTip: React.FC = ({ 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 ( = ({ & { alignment: TipStaticAlignment; + buttonRef?: React.RefObject; escapeKeyPressHandler?: (event: React.KeyboardEvent) => void; id?: string; isTipHidden?: boolean; + onRequestClose?: () => void; popoverContentRef?: React.RefObject; type: 'info' | 'tool' | 'preview'; wrapperRef?: React.RefObject; diff --git a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx index efad164ad61..6e661bf282f 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/InfoTip/InfoTip.stories.tsx @@ -101,6 +101,9 @@ export const WithLinksOrButtons: Story = { } onClick={onClick} /> + ); },