Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(InfoTip): fix DOM ordering + tip not being reread #2909

Merged
merged 19 commits into from
Jul 22, 2024
Merged
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
18 changes: 14 additions & 4 deletions packages/gamut/src/Button/__tests__/IconButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,7 +20,7 @@ export const DeprecatedInlineToolTip: React.FC<DeprecatedToolTipPlacementCompone
const accessibilityProps = getDeprecatedAccessibilityProps({ focusable, id });

return (
<TipWrapper>
<ToolTipWrapper>
<TargetContainer
onKeyDown={(e) => escapeKeyPressHandler(e)}
{...accessibilityProps}
Expand All @@ -42,6 +42,6 @@ export const DeprecatedInlineToolTip: React.FC<DeprecatedToolTipPlacementCompone
{children}
</TipBody>
</ToolTipContainer>
</TipWrapper>
</ToolTipWrapper>
);
};
8 changes: 8 additions & 0 deletions packages/gamut/src/Tip/InfoTip/elements.tsx
Original file line number Diff line number Diff line change
@@ -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' })
);
65 changes: 50 additions & 15 deletions packages/gamut/src/Tip/InfoTip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useEffect, useRef, useState } from 'react';

import { Text } from '../../Typography';
import { FloatingTip } from '../shared/FloatingTip';
import { InlineTip } from '../shared/InlineTip';
import {
TipBaseAlignment,
TipBaseProps,
tipDefaultProps,
} from '../shared/types';
import { ScreenreaderNavigableTaxt } from './elements';
import { InfoTipButton } from './InfoTipButton';

export type InfoTipProps = TipBaseProps & {
Expand All @@ -28,18 +28,34 @@ export const InfoTip: React.FC<InfoTipProps> = ({
...rest
}) => {
const [isTipHidden, setHideTip] = useState(true);
const [isAriaHidden, setIsAriaHidden] = useState(false);
const wrapperRef = 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);
}
} else {
if (isAriaHidden) setIsAriaHidden(false);
setHideTip(nextTipState);
}
};

const escapeKeyPressHandler = (
event: React.KeyboardEvent<HTMLDivElement>
) => {
if (event.key === 'Escape') {
setHideTip(true);
setTipIsHidden(true);
}
};

Expand All @@ -50,13 +66,13 @@ export const InfoTip: React.FC<InfoTipProps> = ({
? !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);
};
Expand All @@ -79,18 +95,37 @@ export const InfoTip: React.FC<InfoTipProps> = ({
...rest,
};

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

const tip = (
<Tip {...tipProps} type="info">
<InfoTipButton
active={!isTipHidden}
emphasis={emphasis}
onClick={() => clickHandler()}
/>
</Tip>
);

// 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}
</>
) : (
<>
<Text screenreader aria-live="assertive">
{!isTipHidden ? info : ''}
</Text>
<Tip {...tipProps} type="info">
<InfoTipButton
active={!isTipHidden}
emphasis={emphasis}
onClick={() => clickHandler()}
/>
</Tip>
{tip}
{text}
</>
);
};
14 changes: 4 additions & 10 deletions packages/gamut/src/Tip/ToolTip/elements.tsx
Original file line number Diff line number Diff line change
@@ -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)<ToolTipContainerProps>`
${TargetContainer}:hover + &,
${TargetContainer}:focus-within + &,
&:hover {
opacity: 1;
visibility: visible;
}
${toolTipAlignmentVariants}
`;
export const ToolTipContainer = styled(Box)<ToolTipContainerProps>(
toolTipAlignmentVariants
);
3 changes: 2 additions & 1 deletion packages/gamut/src/Tip/ToolTip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export const ToolTip: React.FC<ToolTipProps> = ({
return (
<>
{shouldRenderAriaTip && (
<Text screenreader id={id} role="tooltip">
// These are aria-hidden to ensure there's no duplication of content for screen readers navigating with CTRL + OPTION + ARROW
<Text aria-hidden screenreader id={id} role="tooltip">
{adjustedInfo}
</Text>
)}
Expand Down
14 changes: 9 additions & 5 deletions packages/gamut/src/Tip/__tests__/ToolTip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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', () => {
Expand Down
78 changes: 51 additions & 27 deletions packages/gamut/src/Tip/shared/InlineTip.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -18,34 +23,53 @@ export const InlineTip: React.FC<TipPlacementComponentProps> = ({
}) => {
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 (
<TipWrapper>
<TargetContainer
ref={wrapperRef}
onKeyDown={
escapeKeyPressHandler ? (e) => escapeKeyPressHandler(e) : undefined
}
>
{children}
</TargetContainer>
<InlineTipWrapper
alignment={alignment}
zIndex={zIndex ?? 1}
{...InlineWrapperProps}
const target = (
<TargetContainer
ref={wrapperRef}
onKeyDown={
escapeKeyPressHandler ? (e) => escapeKeyPressHandler(e) : undefined
}
>
{children}
</TargetContainer>
);

const tipBody = (
<InlineTipBodyWrapper
alignment={alignment}
zIndex={zIndex ?? 1}
{...InlineWrapperProps}
>
<TipBody
alignment={alignment.includes('center') ? 'centered' : 'aligned'}
color="currentColor"
id={id}
width={narrow ? narrowWidth : undefined}
zIndex="auto"
aria-hidden={isToolType}
>
<TipBody
alignment={alignment.includes('center') ? 'centered' : 'aligned'}
color="currentColor"
id={id}
width={narrow ? narrowWidth : undefined}
zIndex="auto"
>
{info}
</TipBody>
</InlineTipWrapper>
</TipWrapper>
{info}
</TipBody>
</InlineTipBodyWrapper>
);

return (
<InlineTipWrapper>
{alignment.includes('top') ? (
<>
{tipBody}
{target}
</>
) : (
<>
{target}
{tipBody}
</>
)}
</InlineTipWrapper>
);
};
21 changes: 19 additions & 2 deletions packages/gamut/src/Tip/shared/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading