diff --git a/packages/gamut/src/Button/__tests__/IconButton.test.tsx b/packages/gamut/src/Button/__tests__/IconButton.test.tsx index dfde346e29..cea7b37e3d 100644 --- a/packages/gamut/src/Button/__tests__/IconButton.test.tsx +++ b/packages/gamut/src/Button/__tests__/IconButton.test.tsx @@ -43,27 +43,37 @@ describe('IconButton', () => { const { view } = renderView({}); view.getByRole('button', { name: label }); - view.getByRole('tooltip', { name: tipText }); + + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + tipText + ); }); it('renders a tip with both labels when they are not repetitive', async () => { const { view } = renderView({ tip: uniqueTip }); view.getByRole('button', { name: label }); - view.getByRole('tooltip', { name: uniqueTip }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + uniqueTip + ); }); it('renders a true aria-label based on tip when aria-label is not defined', async () => { const { view } = renderView({ 'aria-label': undefined }); view.getByRole('button', { name: label }); - view.getByRole('tooltip', { name: tipText }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + tipText + ); }); it('renders a floating tip', async () => { const { view } = renderFloatingView({}); - view.getByRole('tooltip', { name: tipText }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + tipText + ); + expect(view.queryByText(tip)).toBeNull(); const cta = view.getByRole('button', { name: label }); diff --git a/packages/gamut/src/Tip/DeprecatedToolTip/DeprecatedInlineToolTip.tsx b/packages/gamut/src/Tip/DeprecatedToolTip/DeprecatedInlineToolTip.tsx index 026badae1a..2846bad1d4 100644 --- a/packages/gamut/src/Tip/DeprecatedToolTip/DeprecatedInlineToolTip.tsx +++ b/packages/gamut/src/Tip/DeprecatedToolTip/DeprecatedInlineToolTip.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { TargetContainer, TipBody, TipWrapper } from '../shared/elements'; +import { TargetContainer, TipBody, ToolTipWrapper } from '../shared/elements'; import { escapeKeyPressHandler } from '../shared/utils'; import { ToolTipContainer } from '../ToolTip/elements'; import { @@ -20,7 +20,7 @@ export const DeprecatedInlineToolTip: React.FC + escapeKeyPressHandler(e)} {...accessibilityProps} @@ -42,6 +42,6 @@ export const DeprecatedInlineToolTip: React.FC - + ); }; diff --git a/packages/gamut/src/Tip/InfoTip/elements.tsx b/packages/gamut/src/Tip/InfoTip/elements.tsx new file mode 100644 index 0000000000..25b9ce1a4c --- /dev/null +++ b/packages/gamut/src/Tip/InfoTip/elements.tsx @@ -0,0 +1,8 @@ +import { css } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; + +import { Text } from '../../Typography'; + +export const ScreenreaderNavigableTaxt = styled(Text)( + css({ position: 'relative' }) +); diff --git a/packages/gamut/src/Tip/InfoTip/index.tsx b/packages/gamut/src/Tip/InfoTip/index.tsx index 3d52d32092..4084334a0c 100644 --- a/packages/gamut/src/Tip/InfoTip/index.tsx +++ b/packages/gamut/src/Tip/InfoTip/index.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Text } from '../../Typography'; import { FloatingTip } from '../shared/FloatingTip'; import { InlineTip } from '../shared/InlineTip'; import { @@ -8,6 +7,7 @@ import { TipBaseProps, tipDefaultProps, } from '../shared/types'; +import { ScreenreaderNavigableTaxt } from './elements'; import { InfoTipButton } from './InfoTipButton'; export type InfoTipProps = TipBaseProps & { @@ -28,6 +28,7 @@ export const InfoTip: React.FC = ({ ...rest }) => { const [isTipHidden, setHideTip] = useState(true); + const [isAriaHidden, setIsAriaHidden] = useState(false); const wrapperRef = useRef(null); const [loaded, setLoaded] = useState(false); @@ -35,11 +36,26 @@ export const InfoTip: React.FC = ({ 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); + } + } else { + if (isAriaHidden) setIsAriaHidden(false); + setHideTip(nextTipState); + } + }; + const escapeKeyPressHandler = ( event: React.KeyboardEvent ) => { if (event.key === 'Escape') { - setHideTip(true); + setTipIsHidden(true); } }; @@ -50,13 +66,13 @@ export const InfoTip: React.FC = ({ ? !wrapperRef.current?.contains(e?.target) : true) ) { - setHideTip(true); + setTipIsHidden(true); } }; const clickHandler = () => { const currentTipState = !isTipHidden; - setHideTip(currentTipState); + setTipIsHidden(currentTipState); // we want to call the onClick handler after the tip has mounted if (onClick) setTimeout(() => onClick({ isTipHidden: currentTipState }), 0); }; @@ -79,18 +95,37 @@ export const InfoTip: React.FC = ({ ...rest, }; - return ( + const text = ( + + {!isTipHidden ? info : `\xa0`} + + ); + + const tip = ( + + clickHandler()} + /> + + ); + + // on floating alignment - since this uses React.Portal we're breaking the DOM order so the screenreader text needs to be navigable, in the correct DOM order, and never aria-hidden + + return placement === 'floating' && alignment.includes('top') ? ( + <> + {text} + {tip} + + ) : ( <> - - {!isTipHidden ? info : ''} - - - clickHandler()} - /> - + {tip} + {text} ); }; diff --git a/packages/gamut/src/Tip/ToolTip/elements.tsx b/packages/gamut/src/Tip/ToolTip/elements.tsx index b93908d926..4d65c24f26 100644 --- a/packages/gamut/src/Tip/ToolTip/elements.tsx +++ b/packages/gamut/src/Tip/ToolTip/elements.tsx @@ -1,15 +1,9 @@ import styled from '@emotion/styled'; import { Box } from '../../Box'; -import { TargetContainer, ToolTipContainerProps } from '../shared/elements'; +import { ToolTipContainerProps } from '../shared/elements'; import { toolTipAlignmentVariants } from '../shared/styles'; -export const ToolTipContainer = styled(Box)` - ${TargetContainer}:hover + &, - ${TargetContainer}:focus-within + &, - &:hover { - opacity: 1; - visibility: visible; - } - ${toolTipAlignmentVariants} -`; +export const ToolTipContainer = styled(Box)( + toolTipAlignmentVariants +); diff --git a/packages/gamut/src/Tip/ToolTip/index.tsx b/packages/gamut/src/Tip/ToolTip/index.tsx index 13368a3e8f..5f76d95139 100644 --- a/packages/gamut/src/Tip/ToolTip/index.tsx +++ b/packages/gamut/src/Tip/ToolTip/index.tsx @@ -69,7 +69,8 @@ export const ToolTip: React.FC = ({ return ( <> {shouldRenderAriaTip && ( - + // These are aria-hidden to ensure there's no duplication of content for screen readers navigating with CTRL + OPTION + ARROW + {adjustedInfo} )} diff --git a/packages/gamut/src/Tip/__tests__/ToolTip.test.tsx b/packages/gamut/src/Tip/__tests__/ToolTip.test.tsx index 8793b242e0..eca355f679 100644 --- a/packages/gamut/src/Tip/__tests__/ToolTip.test.tsx +++ b/packages/gamut/src/Tip/__tests__/ToolTip.test.tsx @@ -19,7 +19,9 @@ describe('ToolTip', () => { it('has an accessible tooltip', () => { const { view } = renderView({}); - view.getByRole('tooltip', { name: info }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + info + ); }); it('removes the label text when hasLabel is true', () => { const { view } = renderView({ @@ -29,7 +31,9 @@ describe('ToolTip', () => { }); view.getByRole('button', { name: 'Click' }); - view.getByRole('tooltip', { name: info }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent( + info + ); }); it('hides ariaTooltip when there is no text other than the aria-label', () => { const { view } = renderView({ @@ -66,7 +70,7 @@ describe('floating placement', () => { it('has an accessible tooltip', () => { const { view } = renderView({ placement: 'floating' }); - view.getByRole('tooltip', { name: info }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent(info); }); it('removes the label text when hasRepetitiveLabel is true', () => { const { view } = renderView({ @@ -77,7 +81,7 @@ describe('floating placement', () => { }); view.getByRole('button', { name: 'Click' }); - view.getByRole('tooltip', { name: info }); + expect(view.getByRole('tooltip', { hidden: true })).toHaveTextContent(info); }); it('shows the tip when it is hovered over', () => { const { view } = renderView({ @@ -88,7 +92,7 @@ describe('floating placement', () => { userEvent.hover(view.getByRole('button')); - view.getByRole('tooltip'); + view.getByRole('tooltip', { hidden: true }); expect(view.queryAllByText(info).length).toBe(2); }); it('calls onClick when clicked', () => { diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 2136c75fab..de91b48f03 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -1,6 +1,11 @@ import { InfoTipContainer } from '../InfoTip/styles'; import { ToolTipContainer } from '../ToolTip/elements'; -import { TargetContainer, TipBody, TipWrapper } from './elements'; +import { + InfoTipWrapper, + TargetContainer, + TipBody, + ToolTipWrapper, +} from './elements'; import { narrowWidth } from './styles'; import { TipPlacementComponentProps } from './types'; @@ -18,34 +23,53 @@ export const InlineTip: React.FC = ({ }) => { const isToolType = type === 'tool'; - const InlineTipWrapper = isToolType ? ToolTipContainer : InfoTipContainer; + const InlineTipWrapper = isToolType ? ToolTipWrapper : InfoTipWrapper; + const InlineTipBodyWrapper = isToolType ? ToolTipContainer : InfoTipContainer; const InlineWrapperProps = isToolType ? {} : { hideTip: isTipHidden }; - return ( - - escapeKeyPressHandler(e) : undefined - } - > - {children} - - escapeKeyPressHandler(e) : undefined + } + > + {children} + + ); + + const tipBody = ( + + - - {info} - - - + {info} + + + ); + + return ( + + {alignment.includes('top') ? ( + <> + {tipBody} + {target} + + ) : ( + <> + {target} + {tipBody} + + )} + ); }; diff --git a/packages/gamut/src/Tip/shared/elements.tsx b/packages/gamut/src/Tip/shared/elements.tsx index b1668f9510..26c260c719 100644 --- a/packages/gamut/src/Tip/shared/elements.tsx +++ b/packages/gamut/src/Tip/shared/elements.tsx @@ -10,8 +10,25 @@ import { toolTipBodyCss, } from './styles'; -export const TipWrapper = styled.div( - css({ position: 'relative', display: 'inline-flex' }) +const tipWrapperStyles = { + position: 'relative', + display: 'inline-flex', +} as const; + +export const ToolTipWrapper = styled.div( + css({ + '&:hover > div, &:focus-within > div': { + opacity: 1, + visibility: 'visible', + }, + ...tipWrapperStyles, + }) +); + +export const InfoTipWrapper = styled.div( + css({ + ...tipWrapperStyles, + }) ); enum TargetSelectors {