diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx index ccb951efd56d..98daf451100b 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx @@ -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'; @@ -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'; @@ -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 (
@@ -37,17 +48,11 @@ export function OverviewContent( { beforeContent }: { beforeContent?: ReactNode <>
}> - { 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 ? : welcome }
{ beforeContent } - + ) : ( diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx new file mode 100644 index 000000000000..d436e68b5437 --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx @@ -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 ( + <> + + }>{ text } + + + + ); +}; diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss new file mode 100644 index 000000000000..7dd999ea9b3b --- /dev/null +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss @@ -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); + } +} diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx index 1e00083af818..e8df8e2a044b 100644 --- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx +++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx @@ -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() ); @@ -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( ); + + expect( screen.getByRole( 'heading', { level: 2, name: heading } ) ).toBeInTheDocument(); + expect( typedText() ).toBeInTheDocument(); + } ); + + it( 'shows the heading at once with reduced motion', () => { + setReducedMotion( true ); + render( ); + + 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( ); + if ( played ) { + // jsdom has no AnimationEvent, so React listens for the prefixed name. + fireEvent( screen.getByText( '!' ), new Event( 'webkitAnimationEnd', { bubbles: true } ) ); + } + unmount(); + render( ); + + expect( Boolean( typedText() ) ).toBe( animates ); + } ); + } ); } ); diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx index 47ce20bc8c5f..6878ac32a203 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx @@ -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'; @@ -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(); @@ -90,7 +93,7 @@ const OverviewSections: FC = () => { } return ( -
+
{ isLoading ? ( <> diff --git a/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation b/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation new file mode 100644 index 000000000000..9e239385407f --- /dev/null +++ b/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation @@ -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.