diff --git a/package.json b/package.json
index eaea380f7a..0b056ad867 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap
index 95ea4c64d8..1f51bcf874 100644
--- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap
+++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap
@@ -56,6 +56,7 @@ exports[`Gamut Exported Keys 1`] = `
"HiddenText",
"IconButton",
"iFrameWrapper",
+ "imageStyles",
"InfoTip",
"Input",
"InputStepper",
@@ -78,6 +79,7 @@ exports[`Gamut Exported Keys 1`] = `
"omitProps",
"Overlay",
"Pagination",
+ "PausableImage",
"Popover",
"PopoverContainer",
"ProgressBar",
diff --git a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx b/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx
index aefc4b3018..45b37cb8fd 100644
--- a/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx
+++ b/packages/gamut/src/Markdown/__tests__/Markdown.test.tsx
@@ -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 }) => (
+ <>
+
+ Pause animated image
+ >
+));
+
const basicMarkdown = `
# Heading 1
@@ -205,6 +212,30 @@ var test = true;
screen.getByRole('img');
});
+ it('renders a pausable image when the URL ends with .gif', async () => {
+ renderView({
+ text: `
`,
+ });
+
+ // 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: `
`,
+ });
+
+ // 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: `
# Cool
`,
diff --git a/packages/gamut/src/Markdown/index.tsx b/packages/gamut/src/Markdown/index.tsx
index 777745f67b..bd9cbae8cf 100644
--- a/packages/gamut/src/Markdown/index.tsx
+++ b/packages/gamut/src/Markdown/index.tsx
@@ -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,
@@ -40,6 +42,7 @@ export type SkipDefaultOverridesSettings = {
details?: boolean;
iframe?: boolean;
table?: boolean;
+ img?: boolean;
};
export type MarkdownProps = {
@@ -123,6 +126,11 @@ export class Markdown extends PureComponent {
createInputOverride('checkbox', {
component: MarkdownCheckbox,
}),
+ !skipDefaultOverrides.img &&
+ createImgOverride('img', {
+ component: PausableImage,
+ }),
+ ...overrides,
...standardOverrides,
].filter(Boolean);
diff --git a/packages/gamut/src/Markdown/libs/overrides/index.tsx b/packages/gamut/src/Markdown/libs/overrides/index.tsx
index de4f124d0f..ac9591e695 100644
--- a/packages/gamut/src/Markdown/libs/overrides/index.tsx
+++ b/packages/gamut/src/Markdown/libs/overrides/index.tsx
@@ -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 ;
+ },
+});
diff --git a/packages/gamut/src/PausableImage/BaseImage/__tests__/BaseImage.test.tsx b/packages/gamut/src/PausableImage/BaseImage/__tests__/BaseImage.test.tsx
new file mode 100644
index 0000000000..336facae31
--- /dev/null
+++ b/packages/gamut/src/PausableImage/BaseImage/__tests__/BaseImage.test.tsx
@@ -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 }) => (
+
+));
+
+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'
+ );
+ });
+});
diff --git a/packages/gamut/src/PausableImage/BaseImage/index.tsx b/packages/gamut/src/PausableImage/BaseImage/index.tsx
new file mode 100644
index 0000000000..8303ef002c
--- /dev/null
+++ b/packages/gamut/src/PausableImage/BaseImage/index.tsx
@@ -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 = ({ 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 (
+
+ {/* ensure proper fall back label if an empty string is given as alt */}
+
+ setPaused(!paused)}
+ position="absolute"
+ right={0}
+ variant="secondary"
+ zIndex={1}
+ aria-label={buttonLabel}
+ >
+
+
+
+ );
+};
+
+export default BaseImage;
diff --git a/packages/gamut/src/PausableImage/__tests__/PausableImage.test.tsx b/packages/gamut/src/PausableImage/__tests__/PausableImage.test.tsx
new file mode 100644
index 0000000000..0d315d8cb4
--- /dev/null
+++ b/packages/gamut/src/PausableImage/__tests__/PausableImage.test.tsx
@@ -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 }) => (
+ <>
+
+ Pause animated image
+ >
+));
+
+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');
+ });
+});
diff --git a/packages/gamut/src/PausableImage/index.tsx b/packages/gamut/src/PausableImage/index.tsx
new file mode 100644
index 0000000000..635a321bcb
--- /dev/null
+++ b/packages/gamut/src/PausableImage/index.tsx
@@ -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 = (props) => {
+ const staticImage = ;
+
+ // 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') ? (
+
+
+
+ ) : (
+ staticImage
+ )}
+ >
+ );
+};
diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx
index 01628c9cde..991bbee735 100644
--- a/packages/gamut/src/index.tsx
+++ b/packages/gamut/src/index.tsx
@@ -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';
diff --git a/packages/gamut/src/typings/react-freezeframe.d.ts b/packages/gamut/src/typings/react-freezeframe.d.ts
new file mode 100644
index 0000000000..4ede58f7b3
--- /dev/null
+++ b/packages/gamut/src/typings/react-freezeframe.d.ts
@@ -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 {}
+}
diff --git a/packages/styleguide/CHANGELOG.md b/packages/styleguide/CHANGELOG.md
index 53974aea79..bff54a9900 100644
--- a/packages/styleguide/CHANGELOG.md
+++ b/packages/styleguide/CHANGELOG.md
@@ -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/styleguide@55.0.4...@codecademy/styleguide@55.0.5) (2022-03-08)
diff --git a/packages/styleguide/stories/Molecules/PausableImage.stories.mdx b/packages/styleguide/stories/Molecules/PausableImage.stories.mdx
new file mode 100644
index 0000000000..a53781782f
--- /dev/null
+++ b/packages/styleguide/stories/Molecules/PausableImage.stories.mdx
@@ -0,0 +1,46 @@
+import { PausableImage } from '@codecademy/gamut';
+import title from '@codecademy/macros/lib/title.macro';
+import { PropsTable } from '@codecademy/storybook-addon-variance';
+import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
+
+import { LinkTo } from '~styleguide/blocks';
+
+
+
+
+
+
+
+## Usage
+
+```tsx
+import { PausableImage } from '@codecademy/gamut';
+
+;
+```
+
+## Color Modes
+
+PausableImage components respond to the current Color Mode they are used in. The button and icon color will correctly display for the current color mode without any extra configuration.
+
+- Use the colormode changer of the top of the page to see the change
diff --git a/packages/styleguide/stories/Organisms/Markdown/index.stories.mdx b/packages/styleguide/stories/Organisms/Markdown/index.stories.mdx
index 241e29b955..152d5932e2 100644
--- a/packages/styleguide/stories/Organisms/Markdown/index.stories.mdx
+++ b/packages/styleguide/stories/Organisms/Markdown/index.stories.mdx
@@ -1,4 +1,4 @@
-import { Markdown, Text } from '@codecademy/gamut/src';
+import { Column, LayoutGrid, Markdown, Text } from '@codecademy/gamut/src';
import title from '@codecademy/macros/lib/title.macro';
import { PropsTable } from '@codecademy/storybook-addon-variance';
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
@@ -66,6 +66,38 @@ A common override may be to change the font size of a Heading.
+Img tags are overwitten by the `PausableImage` component because of the accessibility benefits it provides.
+When provided with a Gif it will allow it to be paused.
+
+
+
If you need to override a link, iframe, or table, you do not need to provide the `skipDefaultOverides` prop. This prop is now only used for removing the default heading, paragraph, and list element overrides.