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

feat(PauseableImage): A11y make markdown images pausable #2864

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@
"ts-jest": "29.1.1",
"ts-node": "10.9.1",
"tslib": "2.4.0",
"typescript": "5.1.3"
"typescript": "5.1.3",
"react-freezeframe": "^5.0.2",
"freezeframe": "^5.0.2"
},
"devDependencies": {
"onchange": "^7.0.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ exports[`Gamut Exported Keys 1`] = `
"HiddenText",
"IconButton",
"iFrameWrapper",
"imageStyles",
"InfoTip",
"Input",
"InputStepper",
Expand All @@ -78,6 +79,7 @@ exports[`Gamut Exported Keys 1`] = `
"omitProps",
"Overlay",
"Pagination",
"PausableImage",
"Popover",
"PopoverContainer",
"ProgressBar",
Expand Down
33 changes: 32 additions & 1 deletion packages/gamut/src/Markdown/__tests__/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
/* eslint-disable jsx-a11y/no-distracting-elements */

import { setupRtl } from '@codecademy/gamut-tests';
import { act, screen } from '@testing-library/react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { Markdown } from '../index';

jest.mock('../../PausableImage/BaseImage', () => ({ src }: { src: string }) => (
<>
<img alt="" src={`frozen-${src}`} />
<span>Pause animated image</span>
</>
));

const basicMarkdown = `
# Heading 1

Expand Down Expand Up @@ -205,6 +212,30 @@ var test = true;
screen.getByRole('img');
});

it('renders a pausable image when the URL ends with .gif', async () => {
renderView({
text: `<img src="/image.gif"/>`,
});

// wait to find static image while loading pause ui
screen.getByRole('img');
// wait to find pause button
await waitFor(() => screen.findByText('Pause animated image'));
});

it(`doesn't render a pausable image when the URL doesn't end with .gif`, async () => {
renderView({
text: `<img src="http://google.com/"/>`,
});

// wait to find static image while loading pause ui
screen.getByRole('img');
// wait to find pause button
await waitFor(() =>
expect(screen.queryByText('Pause animated image')).toBeNull()
);
});

it('Allows passing in class names', () => {
renderView({
text: `<div class="narrative-table-container"> # Cool </div>`,
Expand Down
8 changes: 8 additions & 0 deletions packages/gamut/src/Markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { PureComponent } from 'react';
import * as React from 'react';
import sanitizeMarkdown from 'sanitize-markdown';

import { PausableImage } from '../PausableImage';
import { omitProps } from '../utils/omitProps';
import {
createCodeBlockOverride,
createImgOverride,
createInputOverride,
createTagOverride,
MarkdownOverrideSettings,
Expand Down Expand Up @@ -40,6 +42,7 @@ export type SkipDefaultOverridesSettings = {
details?: boolean;
iframe?: boolean;
table?: boolean;
img?: boolean;
};

export type MarkdownProps = {
Expand Down Expand Up @@ -123,6 +126,11 @@ export class Markdown extends PureComponent<MarkdownProps> {
createInputOverride('checkbox', {
component: MarkdownCheckbox,
}),
!skipDefaultOverrides.img &&
createImgOverride('img', {
component: PausableImage,
}),
...overrides,
...standardOverrides,
].filter(Boolean);

Expand Down
31 changes: 31 additions & 0 deletions packages/gamut/src/Markdown/libs/overrides/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,34 @@ export const standardOverrides = [
processNode: processNodeDefinitions.processDefaultNode,
},
];

// Allows for img tag override, which is separate because it doesn't have children
export const createImgOverride = (
tagName: string,
Override: OverrideSettingsBase
) => ({
shouldProcessNode(node: HTMLToReactNode) {
if (!Override) return false;

if (Override.shouldProcessNode) {
return Override.shouldProcessNode(node);
}
return node.name === tagName.toLowerCase();
},
processNode(node: HTMLToReactNode, key: React.Key) {
if (!Override) return null;

const props = {
...processAttributes(node.attribs),
key,
};

if (Override.processNode) {
return Override.processNode(node, props);
}

if (!Override.component) return null;

return <Override.component {...props} />;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { setupRtl } from '@codecademy/gamut-tests';
import userEvent from '@testing-library/user-event';

import { BaseImage } from '..';

jest.mock('react-freezeframe', () => ({ src }: { src: string }) => (
<img alt="" src={`frozen-${src}`} />
));

const renderView = setupRtl(BaseImage, {
alt: '',
src: 'image.gif',
});

describe('BaseImage', () => {
it('renders a playing image by default', () => {
const { view } = renderView();
expect(view.getAllByRole('img')[0]).toHaveAttribute('src', 'image.gif');
});

it('renders a paused image after clicking pause', () => {
const { view } = renderView();

userEvent.click(view.getByRole('button'));

expect(view.getAllByRole('img')[0]).toHaveAttribute(
'src',
'frozen-image.gif'
);
});
});
83 changes: 83 additions & 0 deletions packages/gamut/src/PausableImage/BaseImage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { PauseIcon, PlayIcon } from '@codecademy/gamut-icons';
import { css } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import { useState } from 'react';
import * as React from 'react';
import Freezeframe from 'react-freezeframe';

import { Box } from '../../Box';
import { FillButton } from '../../Button';
import { imageStyles, PausableImageProps } from '..';

export interface BaseImageProps extends PausableImageProps {}

export const Container = styled(Box)(
css({
alignItems: 'center',
justifyContent: 'center',
height: '100%',
display: 'flex',
position: 'relative',
width: '100%',
[`> img,
> .react-freezeframe,
> .react-freezeframe img`]: {
maxWidth: '100%',
height: '100%',
},
'.ff-container': {
height: '100%',
},
'.ff-container .ff-canvas': {
transition: 'none',
},
'.ff-loading-icon::before': {
display: 'none',
},
})
);

export const PlayingImage = imageStyles;

const StyledFreezeframe = styled(Freezeframe)(imageStyles);

export const BaseImage: React.FC<BaseImageProps> = ({ alt, ...rest }) => {
const [paused, setPaused] = useState(false);

const [liveText, buttonLabel, altFallBack, Icon, Image] = paused
? [
`${alt}, paused`,
'Play animated image',
'Playing animated image',
PlayIcon,
StyledFreezeframe,
]
: [
`${alt}, playing`,
'Pause animated image',
'Paused animated image',
PauseIcon,
PlayingImage,
];

return (
<Container>
{/* ensure proper fall back label if an empty string is given as alt */}
<Image alt={alt ? liveText : altFallBack} {...rest} />
<FillButton
bottom={0}
m={8}
onClick={() => setPaused(!paused)}
position="absolute"
right={0}
variant="secondary"
zIndex={1}
aria-label={buttonLabel}
>
<Icon color="currentColor" />
</FillButton>
</Container>
);
};

export default BaseImage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { setupRtl } from '@codecademy/gamut-tests';
import { waitFor } from '@testing-library/react';

import { PausableImage } from '..';

const renderView = setupRtl(PausableImage);

jest.mock('../BaseImage', () => ({ src }: { src: string }) => (
<>
<img alt="" src={`frozen-${src}`} />
<span>Pause animated image</span>
</>
));

describe('PausableImage', () => {
it('renders a pausable image when the URL ends with .gif', async () => {
const { view } = renderView({
src: 'image.gif',
});

// wait to find static image while loading pause ui
view.getByRole('img');
// wait to find pause button
await waitFor(() => view.getByText('Pause animated image'));
});

it('renders a static image when the URL does not end with .gif', () => {
const { view } = renderView({
src: 'image.svg',
});

expect(view.getByRole('img')).toHaveAttribute('src', 'image.svg');
});
});
44 changes: 44 additions & 0 deletions packages/gamut/src/PausableImage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import * as React from 'react';

const BaseImage = React.lazy(() => import('./BaseImage'));

export interface PausableImageProps {
src: string;
alt: string;
}
export const imageStyles = styled.img(
css({
height: 'auto',
maxHeight: '100%',
maxWidth: '100%',
'&[src$=".svg"]': {
width: '100%',
},
})
);

const StaticImage = imageStyles;

export const PausableImage: React.FC<PausableImageProps> = (props) => {
const staticImage = <StaticImage {...props} />;

// Avoid rendering React.Suspense on the server until it's fully supported by React & our applications
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);

return (
<>
{isMounted && props.src?.endsWith('.gif') ? (
<React.Suspense fallback={staticImage}>
<BaseImage {...props} />
</React.Suspense>
) : (
staticImage
)}
</>
);
};
1 change: 1 addition & 0 deletions packages/gamut/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export * from './ModalDeprecated';
export * from './Modals';
export * from './Overlay';
export * from './Pagination';
export * from './PausableImage';
export * from './Popover';
export * from './PopoverContainer';
export * from './ProgressBar';
Expand Down
9 changes: 9 additions & 0 deletions packages/gamut/src/typings/react-freezeframe.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
declare module 'react-freezeframe' {
export interface FreezeframeProps {
className?: string;
src: string;
}

// eslint-disable-next-line react/prefer-stateless-function
export default class Freezframe extends React.Component<FreezeframeProps> {}
}
2 changes: 1 addition & 1 deletion packages/styleguide/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1660,7 +1660,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline

### Features

- **PauseableImage:** :sparkles: Creating new pausable image component ([00c233d](https://github.com/Codecademy/gamut/commit/00c233d56498b819546b41c4dfba872618283044))
- **PausableImage:** :sparkles: Creating new pausable image component ([00c233d](https://github.com/Codecademy/gamut/commit/00c233d56498b819546b41c4dfba872618283044))

### [55.0.5](https://github.com/Codecademy/gamut/compare/@codecademy/[email protected]...@codecademy/[email protected]) (2022-03-08)

Expand Down
Loading
Loading