Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { currentUserCan, getScriptData } from '@automattic/jetpack-script-data';
import { decodeEntities } from '@wordpress/html-entities';
import { __, sprintf } from '@wordpress/i18n';
import { Text } from '@wordpress/ui';
import clsx from 'clsx';
import useSitePurchases from '../../../data/use-site-purchases';
import { getMyJetpackWindowInitialState } from '../../../data/utils/get-my-jetpack-window-state';
import { isHeaderControlsEnabled } from '../../header-controls/slot';
Expand All @@ -10,6 +11,8 @@ import ProductCardsSection from '../../product-cards-section';
import OverviewSections from '../../product-cards-section/overview-sections';
import SeoOptInCard from '../../seo-opt-in-card';
import { A4AUpsell } from './a4a-upsell';
import { TypedText, useOverviewLoadAnimation } from './load-animation';
import loadAnimationStyles from './load-animation/style.module.scss';
import styles from './styles.module.scss';
import type { ReactNode } from 'react';

Expand All @@ -29,6 +32,14 @@ export function OverviewContent( { beforeContent }: { beforeContent?: ReactNode
const name =
getMyJetpackWindowInitialState()?.userFirstName ||
decodeEntities( getScriptData()?.user?.current_user?.display_name ?? '' );
const { animate, onPlayed } = useOverviewLoadAnimation();
const welcome = name
? sprintf(
/* translators: %s: The current user's first name, or their display name. */
__( 'Welcome to Jetpack, %s!', 'jetpack-my-jetpack' ),
name
)
: __( 'Welcome to Jetpack', 'jetpack-my-jetpack' );

return (
<div>
Expand All @@ -37,17 +48,11 @@ export function OverviewContent( { beforeContent }: { beforeContent?: ReactNode
<>
<div className={ styles.welcome }>
<Text variant="heading-xl" render={ <h2 /> }>
{ name
? sprintf(
/* translators: %s: The current user's first name, or their display name. */
__( 'Welcome to Jetpack, %s!', 'jetpack-my-jetpack' ),
name
)
: __( 'Welcome to Jetpack', 'jetpack-my-jetpack' ) }
{ animate ? <TypedText text={ welcome } onTyped={ onPlayed } /> : welcome }
</Text>
</div>
{ beforeContent }
<OverviewSections />
<OverviewSections className={ clsx( { [ loadAnimationStyles.sections ]: animate } ) } />
<OverviewBlogPosts />
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { VisuallyHidden } from '@wordpress/ui';
import { useCallback, useState } from 'react';
import styles from './style.module.scss';
import type { CSSProperties, FC } from 'react';

let hasPlayed = false;

/**
* Whether the Overview's load animation plays: until it has played once in a
* page load, unless the user prefers reduced motion.
*
* @return Whether to animate, and the callback that marks the animation played.
*/
export function useOverviewLoadAnimation(): { animate: boolean; onPlayed: () => void } {
const [ animate ] = useState(
() => ! hasPlayed && ! window.matchMedia?.( '(prefers-reduced-motion: reduce)' ).matches
);
// Marked once played, not on mount, so a mount that is replaced at once doesn't use it up.
const onPlayed = useCallback( () => {
hasPlayed = true;
}, [] );
return { animate, onPlayed };
}

/**
* Splits text into user-perceived characters, so an emoji types as one.
*
* @param text - The text.
* @return The characters.
*/
function splitCharacters( text: string ): string[] {
if ( typeof Intl !== 'undefined' && 'Segmenter' in Intl ) {
return Array.from( new Intl.Segmenter().segment( text ), ( { segment } ) => segment );
}
return Array.from( text );
}

/**
* Text that types itself out with a caret; assistive tech reads it whole.
*
* @param props - Component props.
* @param props.text - The text to type.
* @param props.onTyped - Called when the last character finishes animating.
* @return The rendered text.
*/
export const TypedText: FC< { text: string; onTyped?: () => void } > = ( { text, onTyped } ) => {
const characters = splitCharacters( text );
return (
<>
<span className={ styles[ 'typed-label' ] }>
<VisuallyHidden render={ <span /> }>{ text }</VisuallyHidden>
</span>
<span
aria-hidden="true"
className={ styles.typed }
style={ { '--typed-count': characters.length } as CSSProperties }
>
{ characters.map( ( character, index ) => (
<span
key={ index }
style={ { '--typed-index': index } as CSSProperties }
onAnimationEnd={ index === characters.length - 1 ? onTyped : undefined }
>
{ character }
</span>
) ) }
</span>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copying takes the visible letters, not this copy as well.
.typed-label {
user-select: none;
}

// Letters are laid out from the start and only revealed, so nothing shifts.
.typed {
--typed-step: calc(1s / var(--typed-count));

> span {
position: relative;
animation: typed-letter 400ms ease-out backwards;
animation-delay: calc(var(--typed-index) * var(--typed-step));

// The caret sits on the newest letter's inline end, taking up no space.
&::after {
content: "";
position: absolute;
inset-block: 0.1em;
inset-inline-start: 100%;
border-inline-start: 2px solid currentColor;
opacity: 0;
animation: typed-caret calc(var(--typed-step) * 2) linear;
animation-delay: calc(var(--typed-index) * var(--typed-step));
}

&:last-child::after {
animation-duration: 1s;
animation-iteration-count: 2;
}
}
}

@keyframes typed-letter {

0% {
visibility: hidden;
}

0.01% {
visibility: visible;
color: var(--wpds-color-foreground-content-neutral-weak);
}
}

@keyframes typed-caret {

0%,
49.99% {
opacity: 1;
}

50%,
100% {
opacity: 0;
}
}

// Each section rises in turn, 70ms apart.
.sections > * {
animation: section-rise 300ms ease-out backwards;
animation-delay: calc(var(--section-index, 0) * 70ms);

@for $i from 2 through 3 {
&:nth-child(#{$i}) {
--section-index: #{$i - 1};
}
}
}

@keyframes section-rise {

from {
opacity: 0;
transform: translateY(8px);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { OverviewContent } from '../content';

jest.mock( '../../../../data/use-site-purchases', () => jest.fn() );
Expand Down Expand Up @@ -43,4 +43,71 @@ describe( 'OverviewContent', () => {
.compareDocumentPosition( screen.getByText( 'Notice' ) )
).toBe( Node.DOCUMENT_POSITION_FOLLOWING );
} );

describe( 'load animation', () => {
const heading = 'Welcome to Jetpack, Admin!';
let Content: typeof OverviewContent;
const setReducedMotion = ( matches: boolean ) =>
Object.defineProperty( window, 'matchMedia', {
configurable: true,
value: () => ( { matches } ),
} );
const originalMatchMedia = Object.getOwnPropertyDescriptor( window, 'matchMedia' );

beforeEach( async () => {
window.JetpackScriptData = {
user: { current_user: { display_name: 'Admin', capabilities: {} } },
} as typeof window.JetpackScriptData;
setReducedMotion( false );
// A fresh module registry resets the once-per-page-load flag; React must stay shared.
const react = jest.requireActual( 'react' );
await jest.isolateModulesAsync( async () => {
jest.doMock( 'react', () => react );
( { OverviewContent: Content } = await import( '../content' ) );
} );
} );

afterEach( () => {
if ( originalMatchMedia ) {
Object.defineProperty( window, 'matchMedia', originalMatchMedia );
} else {
delete window.matchMedia;
}
} );

const typedText = () =>
screen.queryByText( ( _, element ) => element?.textContent === heading, {
selector: 'h2 [aria-hidden="true"]',
} );

it( 'types the heading for sighted users only, giving assistive tech the whole of it', () => {
render( <Content /> );

expect( screen.getByRole( 'heading', { level: 2, name: heading } ) ).toBeInTheDocument();
expect( typedText() ).toBeInTheDocument();
} );

it( 'shows the heading at once with reduced motion', () => {
setReducedMotion( true );
render( <Content /> );

expect( typedText() ).not.toBeInTheDocument();
expect( screen.getByRole( 'heading', { level: 2, name: heading } ) ).toBeInTheDocument();
} );

it.each( [
[ 'does not replay on returning to the tab', true, false ],
[ 'still plays when the Overview remounts before it has played', false, true ],
] )( '%s', ( _, played, animates ) => {
const { unmount } = render( <Content /> );
if ( played ) {
// jsdom has no AnimationEvent, so React listens for the prefixed name.
fireEvent( screen.getByText( '!' ), new Event( 'webkitAnimationEnd', { bubbles: true } ) );
}
unmount();
render( <Content /> );

expect( Boolean( typedText() ) ).toBe( animates );
} );
} );
} );
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { dashboard, shield, trendingUp } from '@wordpress/icons';
import { Text } from '@wordpress/ui';
import clsx from 'clsx';
import { PRODUCT_STATUSES } from '../../../constants';
import { PRODUCT_SLUGS } from '../../../data/constants';
import useProduct from '../../../data/products/use-product';
Expand All @@ -20,9 +21,11 @@ import type { FC } from 'react';
/**
* The Overview sections.
*
* @param {object} props - Component props.
* @param {string} props.className - Added once the sections have loaded.
* @return {import('react').JSX.Element} The sections.
*/
const OverviewSections: FC = () => {
const OverviewSections: FC< { className?: string } > = ( { className = '' } ) => {
const { filteredOwnedProducts: slugs, isLoading } = useFilteredProducts();
const { showFullJetpackStatsCard = false } = getMyJetpackWindowInitialState( 'myJetpackFlags' );
const { userIsAdmin = false, canUserViewStats = false } = getMyJetpackWindowInitialState();
Expand Down Expand Up @@ -90,7 +93,7 @@ const OverviewSections: FC = () => {
}

return (
<div className={ styles.sections }>
<div className={ clsx( styles.sections, { [ className ]: ! isLoading } ) }>
{ isLoading ? (
<>
<LoadingBlock width="100%" height="350px" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Overview: Type out the welcome heading and fade the sections in on first load, behind the `my-jetpack-overview-v1` flag.
Loading