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 */} + {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'; + + + + + + {(args) => } + + + + + +## 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. diff --git a/yarn.lock b/yarn.lock index 776ea84a90..41c86b2ef2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7637,30 +7637,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125: - version "1.0.30001252" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001252.tgz" - integrity sha512-I56jhWDGMtdILQORdusxBOH+Nl/KgQSdDmpJezYddnAkVOmnoU8zwjTV9xAjMIYxr0iPreEAVylCGcmHCjfaOw== - -caniuse-lite@^1.0.30001181, caniuse-lite@^1.0.30001286: - version "1.0.30001291" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001291.tgz#08a8d2cfea0b2cf2e1d94dd795942d0daef6108c" - integrity sha512-roMV5V0HNGgJ88s42eE70sstqGW/gwFndosYrikHthw98N5tLnOTxFqMLQjZVRxTWFlJ4rn+MsgXrR7MDPY4jA== - -caniuse-lite@^1.0.30001332: - version "1.0.30001339" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz#f9aece4ea8156071613b27791547ba0b33f176cf" - integrity sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ== - -caniuse-lite@^1.0.30001370: - version "1.0.30001374" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz#3dab138e3f5485ba2e74bd13eca7fe1037ce6f57" - integrity sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw== - -caniuse-lite@^1.0.30001517: - version "1.0.30001529" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001529.tgz#c1f2a411e85fdaace4b1560e1bad078b00ac3181" - integrity sha512-n2pUQYGAkrLG4QYj2desAh+NqsJpHbNmVZz87imptDdxLAtjxary7Df/psdfyDGmskJK/9Dt9cPnx5RZ3CU4Og== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001181, caniuse-lite@^1.0.30001286, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001370, caniuse-lite@^1.0.30001517: + version "1.0.30001610" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001610.tgz" + integrity sha512-QFutAY4NgaelojVMjY63o6XlZyORPaLfyMnsl3HgnWdJUcX6K0oaJymHjH8PT5Gk7sTm8rvC/c5COUQKXqmOMA== capture-exit@^2.0.0: version "2.0.0" @@ -10702,6 +10682,11 @@ framesync@6.0.1: dependencies: tslib "^2.1.0" +freezeframe@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/freezeframe/-/freezeframe-5.0.2.tgz#dca477f1836c3932053719a66244244d46b14348" + integrity sha512-Lp1ltC2vJGBqxdW5U4Hx+JMW+s9KfTPpxKhSTlpmQxYX5oK66y3GIZo56Jie4XtSOApSbfc8SBcsbZzeTNKfFg== + fresh@0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -16685,6 +16670,11 @@ react-focus-on@^3.5.1: use-callback-ref "^1.2.3" use-sidecar "^1.0.1" +react-freezeframe@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/react-freezeframe/-/react-freezeframe-5.0.2.tgz#e1f00dcd1d89827f6de960318552a969feb867e6" + integrity sha512-UOabWMDpklR6Kx/nXw89NzD4IqUdM/wkrMUTOpP7otT/9qfvQOnWsIIqXj9QHt4LwJFYKGBRUJqNh+o/V6Yhow== + react-helmet-async@^1.0.7: version "1.0.9" resolved "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.0.9.tgz#5b9ed2059de6b4aab47f769532f9fbcbce16c5ca"