+
+ );
+}
diff --git a/src/components/MediaCard/README.md b/src/components/MediaCard/README.md
new file mode 100644
index 00000000000..c3dde95affa
--- /dev/null
+++ b/src/components/MediaCard/README.md
@@ -0,0 +1,266 @@
+---
+name: Media card
+category: Structure
+keywords:
+ - MediaCard
+ - actionable
+ - updates
+ - new features
+ - Media card
+ - image card
+ - feature card
+ - card with thumbnail
+ - thumbnail card
+---
+
+# Media card
+
+Media cards provide a consistent layout to present visual information to merchants. Visual media is used to provide additional context to the written information it's paired with.
+
+---
+
+## Best practices
+
+Media cards should:
+
+- Provide merchants with a clear call to action.
+- Always pair text with a visual component, for example, body text paired with an image, video, etc.
+- Use media to enhance the written content. The written content should be able to stand alone without an explanation from the paired media.
+- Show targeted content toward specific audiences to maximize relevance.
+- Be dismissable.
+
+---
+
+## Content guidelines
+
+- Don’t use media cards as advertisements for your feature. Instead they should educate the merchant about how to accomplish tasks related to the section they’re in.
+
+### Title
+
+Media card titles should follow the content guidelines for [headings and subheadings](https://polaris.shopify.com/content/actionable-language#section-headings-and-subheadings).
+
+### Body content
+
+Body content should be:
+
+- Actionable: start sentences with imperative verbs when telling merchants what actions are available to them, especially something new. Don’t use permissive language like “you can”.
+
+
+
+#### Do
+
+Get performance data for all of your sales channels.
+
+#### Don’t
+
+Now you can get performance data for all of your sales channels.
+
+
+
+- Structured for merchant success: always put the most critical information
+ first
+- Clear: use the verb “need” to help merchants understand when they’re required
+ to do something
+
+
+
+#### Do
+
+To buy a shipping label, you need to enter the total weight of your shipment,
+including packaging.
+
+#### Don’t
+
+To buy a shipping label, you must enter the total weight of your shipment,
+including packaging.
+
+
+
+### Call to action
+
+Buttons should be:
+
+Clear and predictable: merchants should be able to anticipate what will happen when they click a button. Never deceive merchants by mislabeling a button.
+
+
+
+#### Do
+
+Buy shipping label
+
+#### Don’t
+
+Buy
+
+
+
+- Action-led: buttons should always lead with a strong verb that encourages
+ action. To provide enough context to merchants use the {verb}+{noun} format on
+ buttons except in the case of common actions like Save, Close, Cancel, or OK.
+
+
+
+#### Do
+
+View shipping settings
+
+#### Don’t
+
+View your settings
+
+
+
+- Scannable: avoid unnecessary words and articles such as the, an, or a.
+
+
+
+#### Do
+
+Add menu item
+
+#### Don’t
+
+Add a menu item
+
+
+
+---
+
+## Examples
+
+### Basic media card
+
+Use to surface educational information about a feature or opportunity.
+
+```jsx
+ {},
+ }}
+ description="Discover how Shopify can power up your entrepreneurial journey."
+ popoverActions={[{content: 'Dismiss', onAction: () => {}}]}
+>
+
+
+```
+
+### Media card with secondary action
+
+Use when there are two distinct actions merchants can take on the information in the card.
+
+```jsx
+ {},
+ }}
+ secondaryAction={{
+ content: 'Learn more',
+ onAction: () => {},
+ }}
+ description="Start your business with eye-catching inventory."
+ popoverActions={[{content: 'Dismiss', onAction: () => {}}]}
+>
+
+
+```
+
+### Video card
+
+Use to provide a consistent layout for contextual learning content. Use to wrap thumbnails of educational videos about Shopify features in context.
+
+```jsx
+ {},
+ }}
+ description={`In this course, you’ll learn how the Kular family turned their mom’s recipe book into a global business.`}
+ popoverActions={[{content: 'Dismiss', onAction: () => {}}]}
+>
+
+
+```
+
+### Portrait video card
+
+Use when vertical screen space is not limited or when the video card is the page’s primary content. For example, in an empty state.
+
+```jsx
+ {},
+ }}
+ description="In this course, you’ll learn how the Kular family turned their mom’s recipe book into a global business."
+ popoverActions={[{content: 'Dismiss', onAction: () => {}}]}
+>
+
+
+```
+
+---
+
+## Related components
+
+- To create a video card, [use the video thumbnail component](https://polaris.shopify.com/components/images-and-icons/video-thumbnail)
+- To group similar concepts and tasks together, [use the card component](https://polaris.shopify.com/components/structure/card)
+- To create page-level layout, [use the layout component](https://polaris.shopify.com/components/structure/layout)
+- To explain a feature that merchants haven’t tried yet, [use the empty state component](https://polaris.shopify.com/components/structure/empty-state)
+
+---
+
+## Accessibility
+
+
+
+See Material Design and development documentation about accessibility for Android:
+
+- [Accessible design on Android](https://material.io/design/usability/accessibility.html)
+- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/)
+
+
+
+
+
+See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS:
+
+- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/)
+- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/)
+
+
+
+
+
+The required `title` prop gives the media card a level 2 heading (`
`). This helps with readability and provides structure to screen reader users.
+
+Use [actionable language](https://polaris.shopify.com/content/actionable-language#navigation) to ensure that the purpose of the media card is clear to all merchants, including those with issues related to reading and language.
+
+
diff --git a/src/components/MediaCard/index.ts b/src/components/MediaCard/index.ts
new file mode 100644
index 00000000000..5d2683caa72
--- /dev/null
+++ b/src/components/MediaCard/index.ts
@@ -0,0 +1 @@
+export * from './MediaCard';
diff --git a/src/components/MediaCard/tests/MediaCard.test.tsx b/src/components/MediaCard/tests/MediaCard.test.tsx
new file mode 100644
index 00000000000..5487dc58c79
--- /dev/null
+++ b/src/components/MediaCard/tests/MediaCard.test.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import {Heading, Popover, Button, ActionList} from 'components';
+import {mountWithApp} from 'test-utilities';
+
+import {MediaCard} from '../MediaCard';
+
+const mockProps = {
+ children: ,
+ title: 'test title',
+ description: 'test description',
+ primaryAction: {
+ content: 'test primary action',
+ onAction: () => {},
+ },
+};
+
+describe('', () => {
+ it('renders the title as a Heading', () => {
+ const title = 'Getting Started';
+ const videoCard = mountWithApp();
+
+ expect(videoCard).toContainReactComponent(Heading, {children: title});
+ });
+
+ it('renders the description as a paragraph', () => {
+ const description = 'test';
+ const videoCard = mountWithApp(
+ ,
+ );
+
+ expect(videoCard).toContainReactComponent('p', {children: description});
+ });
+
+ it('renders a Button with the primaryAction', () => {
+ const primaryAction = {content: 'test primary action'};
+ const videoCard = mountWithApp(
+ ,
+ );
+
+ expect(videoCard).toContainReactComponent(Button, {
+ children: primaryAction.content,
+ });
+ });
+
+ it('renders secondaryAction as a plain Button', () => {
+ const secondaryAction = {content: 'test'};
+ const videoCard = mountWithApp(
+ ,
+ );
+
+ expect(videoCard).toContainReactComponent(Button, {
+ children: secondaryAction.content,
+ });
+ });
+
+ it('renders a Popover and ActionList when popoverActions are provided', () => {
+ const actions = [{content: 'Dismiss'}];
+ const videoCard = mountWithApp(
+ ,
+ );
+
+ expect(videoCard).toContainReactComponentTimes(Popover, 1);
+
+ const popoverActivator = videoCard.find(Popover)!.find(Button);
+ popoverActivator!.trigger('onClick');
+
+ expect(videoCard).toContainReactComponent(ActionList, {
+ items: actions,
+ });
+ });
+
+ it('does not render a Popover if popoverActions are empty', () => {
+ const videoCard = mountWithApp(
+ ,
+ );
+
+ expect(videoCard).not.toContainReactComponent(Popover);
+ });
+
+ it('renders in landscape mode by default', () => {
+ const videoCard = mountWithApp();
+
+ expect(videoCard.find('div')).toContainReactComponentTimes('div', 0, {
+ className: 'portrait',
+ });
+ });
+});
diff --git a/src/components/VideoThumbnail/README.md b/src/components/VideoThumbnail/README.md
new file mode 100644
index 00000000000..c25ff2a54bd
--- /dev/null
+++ b/src/components/VideoThumbnail/README.md
@@ -0,0 +1,97 @@
+---
+name: Video thumbnail
+category: Images and icons
+keywords:
+ - video
+ - VideoThumbnail
+ - updates
+ - new features
+ - video thumbnail
+ - feature thumbnail
+ - education
+ - contextual learning system
+---
+
+# Video thumbnail
+
+Video thumbnails are a clickable placeholder image. When clicked, it opens a video player within a modal or full screen.
+
+---
+
+## Best practices
+
+Video thumbnails should:
+
+- Be used with a media card
+- Use an image that communicates the subject of the video
+- Include a video timestamp
+- Capture an image from the video to give a preview of the video content
+- Be cropped to a 16:9 aspect ratio
+- Be centered on the subject and avoid cropping of important details, like a person’s head
+
+---
+
+## Examples
+
+### Basic video thumbnail
+
+Use as a play button for a video player within a media card.
+
+```jsx
+ {},
+ }}
+ description={`In this course, you’ll learn how the Kular family turned their mom’s recipe book into a global business.`}
+ popoverActions={[{content: 'Dismiss', onAction: () => {}}]}
+>
+
+
+```
+
+---
+
+## Required components
+
+- The video thumbnail should be wrapped in the [media card](https://polaris.shopify.com/components/structure/media-card) component.
+
+---
+
+## Related components
+
+- To present a small visual anchor for an object, [use the thumbnail component](https://polaris.shopify.com/components/images-and-icons/thumbnail)
+
+---
+
+## Accessibility
+
+
+
+See Material Design and development documentation about accessibility for Android:
+
+- [Accessible design on Android](https://material.io/design/usability/accessibility.html)
+- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/)
+
+
+
+
+
+See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS:
+
+- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/)
+- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/)
+
+
+
+
+
+Images included in video thumbnails are implemented as decorative background images so that they’re skipped by screen readers.
+
+The play button is keyboard accessible and the `aria-label` includes a timestamp when the `videoLength` prop is set. For example, an 80 second video reads as “Play video of length 1 minute and 20 seconds”. If no `videoLength` prop is provided, the default label reads “Play video”.
+
+
diff --git a/src/components/VideoThumbnail/VideoThumbnail.scss b/src/components/VideoThumbnail/VideoThumbnail.scss
new file mode 100644
index 00000000000..25f6067d447
--- /dev/null
+++ b/src/components/VideoThumbnail/VideoThumbnail.scss
@@ -0,0 +1,70 @@
+@import '../../styles/common';
+$start-button-size: 60px;
+
+.Thumbnail {
+ position: relative;
+ // Accomodating 16:9 responsive block for video
+ padding-bottom: 9 / 16 * 100%;
+ background-size: cover;
+ background-position: center center;
+ background-repeat: no-repeat;
+ width: 100%;
+ height: 100%;
+
+ &.WithPlayer {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ padding-bottom: auto;
+ }
+}
+
+.PlayButton {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: none;
+ background: transparent;
+ opacity: 0.8;
+ transition: opacity 0.2s ease-in;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ opacity: 1;
+ }
+
+ &:focus {
+ outline: none;
+ @include state(focused);
+ }
+}
+
+.PlayIcon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: rem($start-button-size);
+ height: rem($start-button-size);
+ margin-top: rem(-$start-button-size / 2);
+ margin-left: rem(-$start-button-size / 2);
+}
+
+.Timestamp {
+ position: absolute;
+ bottom: 0;
+ padding: 0 spacing(extra-tight);
+ margin-bottom: spacing(tight);
+ margin-left: spacing(tight);
+ border-radius: var(--p-border-radius-base, border-radius());
+ color: var(--p-text, color('sky', 'lighter'));
+ background-color: var(--p-surface, color('ink'));
+ opacity: 0.8;
+ text-align: center;
+}
diff --git a/src/components/VideoThumbnail/VideoThumbnail.tsx b/src/components/VideoThumbnail/VideoThumbnail.tsx
new file mode 100644
index 00000000000..a3b93e1af23
--- /dev/null
+++ b/src/components/VideoThumbnail/VideoThumbnail.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+
+import {useI18n} from '../../utilities/i18n';
+import {
+ secondsToTimeComponents,
+ secondsToTimestamp,
+ secondsToDurationTranslationKey,
+} from '../../utilities/duration';
+
+import {PlayIcon} from './illustrations';
+import styles from './VideoThumbnail.scss';
+
+export interface VideoThumbnailProps {
+ /** URL source for thumbnail image. */
+ thumbnailUrl: string;
+ /** Length of video in seconds. */
+ videoLength?: number;
+ /** Custom ARIA label for play button.
+ * @default 'Play video of length {human readable duration}'
+ */
+ accessibilityLabel?: string;
+ /** Callback on click or keypress of thumbnail. Use to trigger render of the video player in your chosen format, for example within a modal or fullscreen container. */
+ onClick(): void;
+ /** Callback on mouse enter, focus, or touch start of thumbnail. Use to trigger video preload. */
+ onBeforeStartPlaying?(): void;
+}
+
+export function VideoThumbnail({
+ thumbnailUrl,
+ videoLength,
+ accessibilityLabel,
+ onClick,
+ onBeforeStartPlaying,
+}: VideoThumbnailProps) {
+ const i18n = useI18n();
+ let buttonLabel;
+
+ if (accessibilityLabel) {
+ buttonLabel = accessibilityLabel;
+ } else if (videoLength) {
+ const {hours, minutes, seconds} = secondsToTimeComponents(videoLength);
+
+ buttonLabel = i18n.translate(
+ 'Polaris.VideoThumbnail.playButtonA11yLabel.defaultWithDuration',
+ {
+ duration: i18n.translate(secondsToDurationTranslationKey(videoLength), {
+ hourCount: hours,
+ minuteCount: minutes,
+ secondCount: seconds,
+ }),
+ },
+ );
+ } else {
+ buttonLabel = i18n.translate(
+ 'Polaris.VideoThumbnail.playButtonA11yLabel.default',
+ );
+ }
+
+ const timeStampMarkup = videoLength ? (
+
{secondsToTimestamp(videoLength)}
+ ) : null;
+
+ return (
+
+
+ {timeStampMarkup}
+
+ );
+}
diff --git a/src/components/VideoThumbnail/illustrations/index.ts b/src/components/VideoThumbnail/illustrations/index.ts
new file mode 100644
index 00000000000..f65021f49a0
--- /dev/null
+++ b/src/components/VideoThumbnail/illustrations/index.ts
@@ -0,0 +1 @@
+export {default as PlayIcon} from './play.svg';
diff --git a/src/components/VideoThumbnail/illustrations/play.svg b/src/components/VideoThumbnail/illustrations/play.svg
new file mode 100644
index 00000000000..11df3551df2
--- /dev/null
+++ b/src/components/VideoThumbnail/illustrations/play.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/VideoThumbnail/index.ts b/src/components/VideoThumbnail/index.ts
new file mode 100644
index 00000000000..3384afea11b
--- /dev/null
+++ b/src/components/VideoThumbnail/index.ts
@@ -0,0 +1 @@
+export * from './VideoThumbnail';
diff --git a/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx b/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx
new file mode 100644
index 00000000000..b4344361bcd
--- /dev/null
+++ b/src/components/VideoThumbnail/tests/VideoThumbnail.test.tsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import {mountWithApp} from 'test-utilities';
+
+import {VideoThumbnail} from '../VideoThumbnail';
+
+describe('', () => {
+ const spyClick = jest.fn();
+ const mockProps = {
+ thumbnailUrl: '',
+ onClick: spyClick,
+ };
+
+ describe('thumbnailUrl', () => {
+ it('renders with play button and custom overlay', () => {
+ const videoThumbnail = mountWithApp();
+
+ expect(videoThumbnail.find('button')).not.toBeNull();
+ expect(
+ videoThumbnail.find('div', {className: 'Thumbnail'})!.prop('style')!
+ .backgroundImage,
+ ).toBe(`url(${mockProps.thumbnailUrl})`);
+ });
+ });
+
+ describe('videoLength', () => {
+ it('does not render a timestamp if not provided', () => {
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+ expect(videoThumbnail).not.toContainReactComponent('p', {
+ className: 'Timestamp',
+ });
+ });
+
+ it('renders a timestamp with seconds only when less than 60 seconds', () => {
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const timestamp = videoThumbnail
+ .find('p', {
+ className: 'Timestamp',
+ })
+ ?.text();
+
+ expect(timestamp).toStrictEqual('0:45');
+ });
+
+ it('renders a timestamp with seconds and minutes only when less than 60 minutes', () => {
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const timestamp = videoThumbnail
+ .find('p', {
+ className: 'Timestamp',
+ })
+ ?.text();
+
+ expect(timestamp).toStrictEqual('2:15');
+ });
+
+ it('renders timestamp with seconds, minutes, and hours when greater than 60 minutes', () => {
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const timestamp = videoThumbnail
+ .find('p', {
+ className: 'Timestamp',
+ })
+ ?.text();
+
+ expect(timestamp).toStrictEqual('1:02:25');
+ });
+ });
+
+ describe('aria-label', () => {
+ it('sets the accessibilityLabel on the aria-label attribute when provided', () => {
+ const accessibilityLabel = 'test';
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+ expect(videoThumbnail.find('button')!.prop('aria-label')).toStrictEqual(
+ accessibilityLabel,
+ );
+ });
+
+ describe('when videoLength is provided', () => {
+ const defaultLabelWithDuration = 'Play video of length';
+
+ it('sets the default label with time in seconds when less than 60 seconds', () => {
+ const videoLength = 45;
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const actualLabel = videoThumbnail.find('button')!.prop('aria-label');
+ const expectedLabel = `${defaultLabelWithDuration} 45 seconds`;
+
+ expect(actualLabel).toStrictEqual(expectedLabel);
+ });
+
+ it('sets the default label with time in seconds and minutes when less than 60 minutes', () => {
+ const videoLength = 135;
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const actualLabel = videoThumbnail.find('button')!.prop('aria-label');
+ const expectedLabel = `${defaultLabelWithDuration} 2 minutes and 15 seconds`;
+
+ expect(actualLabel).toStrictEqual(expectedLabel);
+ });
+
+ it('sets the default label with time in seconds, minutes, and hours when greater than 60 minutes', () => {
+ const videoLength = 3745;
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+
+ const actualLabel = videoThumbnail.find('button')!.prop('aria-label');
+ const expectedLabel = `${defaultLabelWithDuration} 1 hour, 2 minutes, and 25 seconds`;
+
+ expect(actualLabel).toStrictEqual(expectedLabel);
+ });
+ });
+ });
+
+ describe('onClick', () => {
+ it('calls the onClick when the play button is clicked', () => {
+ const videoThumbnail = mountWithApp();
+ videoThumbnail.find('button')!.trigger('onClick');
+ expect(spyClick).toHaveBeenCalled();
+ });
+ });
+
+ describe('onBeforeStartPlaying', () => {
+ it('calls the onMouseEnter when the enter button is pressed', () => {
+ const spyOnBeforeStart = jest.fn();
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+ videoThumbnail.find('button')!.trigger('onMouseEnter');
+ expect(spyOnBeforeStart).toHaveBeenCalled();
+ });
+
+ it('calls the onTouchStart when the play button is pressed', () => {
+ const spyOnBeforeStart = jest.fn();
+ const videoThumbnail = mountWithApp(
+ ,
+ );
+ videoThumbnail.find('button')!.trigger('onTouchStart');
+ expect(spyOnBeforeStart).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/index.ts b/src/components/index.ts
index 85d4bbda179..8593d87869e 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -158,6 +158,8 @@ export type {ListProps} from './List';
export {Loading} from './Loading';
export type {LoadingProps} from './Loading';
+export {MediaCard} from './MediaCard';
+
export {Modal} from './Modal';
export type {ModalProps} from './Modal';
@@ -295,5 +297,7 @@ export type {TruncateProps} from './Truncate';
export {UnstyledLink} from './UnstyledLink';
export type {UnstyledLinkProps} from './UnstyledLink';
+export {VideoThumbnail} from './VideoThumbnail';
+
export {VisuallyHidden} from './VisuallyHidden';
export type {VisuallyHiddenProps} from './VisuallyHidden';
diff --git a/src/utilities/duration.ts b/src/utilities/duration.ts
new file mode 100644
index 00000000000..15cacef3de6
--- /dev/null
+++ b/src/utilities/duration.ts
@@ -0,0 +1,65 @@
+const MINUTE = 60;
+const HOUR = MINUTE * 60;
+
+export function ensureTwoDigits(num: number): string {
+ return num > 9 ? String(num) : `0${num}`;
+}
+
+export function secondsToTimeComponents(
+ seconds: number,
+): {hours: number; minutes: number; seconds: number} {
+ return {
+ hours: Math.floor(seconds / HOUR),
+ minutes: Math.floor((seconds % HOUR) / MINUTE),
+ seconds: seconds % MINUTE,
+ };
+}
+
+export function secondsToTimestamp(numSeconds: number) {
+ const {hours, minutes, seconds} = secondsToTimeComponents(numSeconds);
+ const hasHours = numSeconds > HOUR;
+ const hoursText = hasHours ? `${hours}:` : '';
+ const minutesText = `${hasHours ? ensureTwoDigits(minutes) : minutes}:`;
+ const secondsText = `${ensureTwoDigits(seconds)}`;
+
+ return `${hoursText}${minutesText}${secondsText}`;
+}
+
+export function secondsToDurationTranslationKey(numSeconds: number) {
+ const {hours, minutes, seconds} = secondsToTimeComponents(numSeconds);
+ let durationKey = 'Polaris.VideoThumbnail.playButtonA11yLabel.duration';
+
+ if (hours) {
+ durationKey += `.hours.${hours > 1 ? 'other' : 'one'}`;
+
+ if (seconds) {
+ if (minutes > 1) {
+ durationKey += `${
+ seconds > 1 ? '.minutesAndSeconds' : '.minutesAndSecond'
+ }`;
+ } else if (minutes === 1) {
+ durationKey += `${
+ seconds > 1 ? '.minuteAndSeconds' : '.minuteAndSecond'
+ }`;
+ } else {
+ durationKey += `${seconds > 1 ? '.andSeconds' : '.andSecond'}`;
+ }
+ } else if (minutes) {
+ durationKey += `${minutes > 1 ? '.andMinutes' : '.andMinute'}`;
+ } else {
+ durationKey += '.only';
+ }
+ } else if (minutes) {
+ durationKey += `.minutes.${minutes > 1 ? 'other' : 'one'}`;
+
+ if (seconds) {
+ durationKey += `${seconds > 1 ? '.andSeconds' : '.andSecond'}`;
+ } else {
+ durationKey += '.only';
+ }
+ } else if (seconds) {
+ durationKey += seconds > 1 ? '.seconds.other' : '.seconds.one';
+ }
+
+ return durationKey;
+}
diff --git a/src/utilities/tests/duration.test.ts b/src/utilities/tests/duration.test.ts
new file mode 100644
index 00000000000..b9b5095bfd6
--- /dev/null
+++ b/src/utilities/tests/duration.test.ts
@@ -0,0 +1,197 @@
+import {
+ ensureTwoDigits,
+ secondsToTimeComponents,
+ secondsToTimestamp,
+ secondsToDurationTranslationKey,
+} from '../duration';
+
+describe(' utilities', () => {
+ describe('ensureTwoDigits', () => {
+ it('stringifies the number when greater than 9', () => {
+ expect(ensureTwoDigits(12)).toStrictEqual('12');
+ });
+
+ it('stringifies the number with a leading zero when less than 9', () => {
+ expect(ensureTwoDigits(8)).toStrictEqual('08');
+ });
+ });
+
+ describe('secondsToTimeComponents', () => {
+ it('sets hours and minutes to zero when numSeconds is < 60 seconds', () => {
+ const actualTimeComponents = secondsToTimeComponents(45);
+ const expectedTimeComponents = {hours: 0, minutes: 0, seconds: 45};
+
+ expect(actualTimeComponents).toStrictEqual(expectedTimeComponents);
+ });
+
+ it('sets hours to zero when numSeconds is > 60 seconds and < 60 minutes', () => {
+ const actualTimeComponents = secondsToTimeComponents(145);
+ const expectedTimeComponents = {hours: 0, minutes: 2, seconds: 25};
+
+ expect(actualTimeComponents).toStrictEqual(expectedTimeComponents);
+ });
+
+ it('sets hours and minutes values when numSeconds is > 60 minutes', () => {
+ const actualTimeComponents = secondsToTimeComponents(3745);
+ const expectedTimeComponents = {hours: 1, minutes: 2, seconds: 25};
+
+ expect(actualTimeComponents).toStrictEqual(expectedTimeComponents);
+ });
+ });
+
+ describe('secondsToTimestamp', () => {
+ describe('when numSeconds is > 60 minutes', () => {
+ it('includes hours in the timestamp', () => {
+ expect(secondsToTimestamp(4745)).toStrictEqual('1:19:05');
+ });
+
+ it('adds a leading zero to minutes when less than 10', () => {
+ expect(secondsToTimestamp(3745)).toStrictEqual('1:02:25');
+ });
+ });
+
+ describe('when numSeconds is > 60 seconds and < 60 minutes', () => {
+ it('does not include hours in the timestamp', () => {
+ expect(secondsToTimestamp(745)).toStrictEqual('12:25');
+ });
+
+ it('does not add a leading zero to minutes when less than 10', () => {
+ expect(secondsToTimestamp(145)).toStrictEqual('2:25');
+ });
+ });
+ });
+
+ describe('secondsToDurationTranslationKey', () => {
+ describe('when numSeconds is > 60 minutes', () => {
+ it('sets the ".hours.other" translation keys when hours is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(7745);
+ const expectedKey = '.hours.other';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".hours.one" translation keys when hours equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3745);
+ const expectedKey = '.hours.one';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".minutesAndSeconds" translation key when minutes and seconds are both greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3745);
+ const expectedKey = '.minutesAndSeconds';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".minutesAndSecond" translation key when minutes is greater than 1 and seconds are equal to 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3721);
+ const expectedKey = '.minutesAndSecond';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".minuteAndSeconds" translation key when minutes equals 1 and seconds is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3680);
+ const expectedKey = '.minuteAndSeconds';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".minuteAndSecond" translation key when minutes and seconds are both equal to 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3661);
+ const expectedKey = '.minuteAndSecond';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andMinutes" translation key when there are zero seconds and minutes is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3720);
+ const expectedKey = '.andMinutes';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andMinute" translation key when there are zero seconds and minutes equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3660);
+ const expectedKey = '.andMinute';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andSeconds" translation key when there are zero minutes and seconds is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3608);
+ const expectedKey = '.andSeconds';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andSecond" translation key when there are zero minutes and seconds equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(3601);
+ const expectedKey = '.andSecond';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".only" translation key when there are zero minutes and seconds', () => {
+ const actualKey = secondsToDurationTranslationKey(3600);
+ const expectedKey = '.only';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+ });
+
+ describe('when numSeconds is > 60 seconds and < 60 minutes', () => {
+ it('sets the ".minutes.other" translation key when minutes is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(145);
+ const expectedKey = '.minutes.other';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".minutes.one" translation key when minutes equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(80);
+ const expectedKey = '.minutes.one';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andSeconds" translation key when seconds is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(145);
+ const expectedKey = '.andSeconds';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".andSecond" translation key when seconds equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(61);
+ const expectedKey = '.andSecond';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".only" translation key when there are zero seconds', () => {
+ const actualKey = secondsToDurationTranslationKey(120);
+ const expectedKey = '.only';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+ });
+
+ describe('when numSeconds is < 60 seconds', () => {
+ it('sets the ".seconds.other" translation key when seconds is greater than 1', () => {
+ const actualKey = secondsToDurationTranslationKey(45);
+ const expectedKey = '.seconds.other';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+
+ it('sets the ".seconds.one" translation key when seconds equals 1', () => {
+ const actualKey = secondsToDurationTranslationKey(1);
+ const expectedKey = '.seconds.one';
+
+ expect(actualKey).toContain(expectedKey);
+ });
+ });
+ });
+});