Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement welcome guide modal #18041

Merged
merged 22 commits into from Dec 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fab075e
Implement welcome guide modal
noisysocks Oct 21, 2019
5d35bf0
Welcome guide: Update images
noisysocks Nov 22, 2019
8d73323
Welcome guide: Capitalise 'Block Editor' and 'Block Library'
noisysocks Nov 22, 2019
d596283
Welcome guide: Position close button 24px from edge
noisysocks Nov 22, 2019
eae9ce0
Welcome guide: Improve a11y of page controls
noisysocks Nov 22, 2019
a10e712
Welcome guide: Add unit tests
noisysocks Nov 22, 2019
3fdce05
Welcome guide: Add E2E tests
noisysocks Nov 25, 2019
a88b564
Welcome guide: Mark Guide as __experimental, as we may want to move i…
noisysocks Nov 25, 2019
1905df4
Welcome guide: Add README.md
noisysocks Nov 25, 2019
4c3682f
Welcome guide: Rename WelcomeGuideModal to WelcomeGuide
noisysocks Nov 25, 2019
c242fef
Welcome guide: Update fixtures
noisysocks Nov 25, 2019
ed026a1
Welcome guide: Rename Guide.Page to GuidePage
noisysocks Nov 28, 2019
6614977
Welcome guide: Remove unnecessary onSelect prop
noisysocks Nov 28, 2019
cf6dbee
Welcome guide: Move Guide to @wordpress/components
noisysocks Nov 28, 2019
0c1594a
Welcome guide: Remove snapshot test
noisysocks Nov 28, 2019
c207456
Welcome guide: Use CSS to hide and show finish buton
noisysocks Nov 28, 2019
b2de4df
Welcome guide: Add a storybook
noisysocks Nov 28, 2019
5f1f9db
Welcome guide: Use break-small() mixin
noisysocks Nov 28, 2019
113c7aa
Welcome guide: Generate storybook snapshot
noisysocks Nov 28, 2019
13f59a3
Welcome guide: Update @wordpress/components changelog
noisysocks Nov 29, 2019
2352da6
E2E tests: Reload page after disabling welcome guide / tips
noisysocks Nov 29, 2019
7b8773d
Welcome guide: Close guide when user clicks on overlay
noisysocks Dec 2, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/manifest-devhub.json
Expand Up @@ -731,6 +731,12 @@
"markdown_source": "../packages/components/src/form-token-field/README.md",
"parent": "components"
},
{
"title": "Guide",
"slug": "guide",
"markdown_source": "../packages/components/src/guide/README.md",
"parent": "components"
},
{
"title": "NavigateRegions",
"slug": "navigate-regions",
Expand Down
6 changes: 6 additions & 0 deletions packages/components/CHANGELOG.md
@@ -1,3 +1,9 @@
# 8.3.0 (Unreleased)

### New Features

- Added a new `Guide` component which allows developers to easily present a user guide.

## 8.2.0 (2019-08-29)

### New Features
Expand Down
56 changes: 56 additions & 0 deletions packages/components/src/guide/README.md
@@ -0,0 +1,56 @@
Guide
========

`Guide` is a React component that renders a _user guide_ in a modal. The guide consists of several `GuidePage` components which the user can step through one by one. The guide is finished when the modal is closed or when the user clicks _Finish_ on the last page of the guide.

## Usage

```jsx
function MyTutorial() {
const [ isOpen, setIsOpen ] = useState( true );

if ( ! isOpen ) {
return null;
}

<Guide onFinish={ () => setIsOpen( false ) }>
<GuidePage>
<p>Welcome to the ACME Store! Select a category to begin browsing our wares.</p>
</GuidePage>
<GuidePage>
<p>When you find something you love, click <i>Add to Cart</i> to add the product to your shopping cart.</p>
</GuidePage>
</Guide>
}
```

## Props

The component accepts the following props:

### onFinish

A function which is called when the guide is finished. The guide is finished when the modal is closed or when the user clicks _Finish_ on the last page of the guide.

- Type: `function`
- Required: Yes

### children

A list of `GuidePage` components. One page is shown at a time.

- Required: Yes

### className

A custom class to add to the modal.

- Type: `string`
- Required: No

### finishButtonText

Use this to customize the label of the _Finish_ button shown at the end of the guide.

- Type: `string`
- Required: No
33 changes: 33 additions & 0 deletions packages/components/src/guide/finish-button.js
@@ -0,0 +1,33 @@
/**
* WordPress dependencies
*/
import { useRef, useLayoutEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import Button from '../button';

export default function FinishButton( { className, onClick, children } ) {
const button = useRef( null );

// Focus the button on mount if nothing else is focused. This prevents a
// focus loss when the 'Next' button is swapped out.
useLayoutEffect( () => {
if ( document.activeElement === document.body ) {
Copy link
Member

@aduth aduth Mar 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In #20594, I am encountering an issue where we make certain assumptions about what it means for "no focus" to exist. It appears that the value of activeElement may differ depending on browser:

When there is no selection, the active element is the page's <body> or null.

https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/activeElement

I mention it here, since this may not cover all focus losses, notably those in Internet Explorer. Specifically, this condition is only checking for the first of these two possible "no active element" scenarios.

image

It might be something where we want to provide some utility, e.g. wp.dom.hasActiveElement, since it seems to be an easy issue to overlook.

Edit: I confirmed that there is a focus loss which occurs when reaching the last page of the guide in Internet Explorer:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix at #20599

button.current.focus();
}
}, [ button ] );

return (
<Button
ref={ button }
className={ className }
isPrimary
isLarge
onClick={ onClick }
>
{ children }
</Button>
);
}
24 changes: 24 additions & 0 deletions packages/components/src/guide/icons.js
@@ -0,0 +1,24 @@
/**
* Internal dependencies
*/
import { SVG, Path, Circle } from '../primitives/svg';

export const BackButtonIcon = () => (
<SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<Path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
<Path d="M0 0h24v24H0z" fill="none" />
</SVG>
);

export const ForwardButtonIcon = () => (
<SVG xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<Path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
<Path d="M0 0h24v24H0z" fill="none" />
</SVG>
);

export const PageControlIcon = ( { isSelected } ) => (
<SVG width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg">
<Circle cx="6" cy="6" r="6" fill={ isSelected ? '#419ECD' : '#E1E3E6' } />
</SVG>
);
107 changes: 107 additions & 0 deletions packages/components/src/guide/index.js
@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useState, Children } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import Modal from '../modal';
import KeyboardShortcuts from '../keyboard-shortcuts';
import IconButton from '../icon-button';
import PageControl from './page-control';
import { BackButtonIcon, ForwardButtonIcon } from './icons';
import FinishButton from './finish-button';

export default function Guide( { children, className, finishButtonText, onFinish } ) {
const [ currentPage, setCurrentPage ] = useState( 0 );

const numberOfPages = Children.count( children );
const canGoBack = currentPage > 0;
const canGoForward = currentPage < numberOfPages - 1;

const goBack = () => {
if ( canGoBack ) {
setCurrentPage( currentPage - 1 );
}
};

const goForward = () => {
if ( canGoForward ) {
setCurrentPage( currentPage + 1 );
}
};

if ( numberOfPages === 0 ) {
return null;
}

return (
<Modal
className={ classnames( 'components-guide', className ) }
onRequestClose={ onFinish }
>

<KeyboardShortcuts key={ currentPage } shortcuts={ {
left: goBack,
right: goForward,
} } />

<div className="components-guide__container">

{ children[ currentPage ] }

{ ! canGoForward && (
<FinishButton
className="components-guide__inline-finish-button"
onClick={ onFinish }
>
{ finishButtonText || __( 'Finish' ) }
</FinishButton>
) }

<div className="components-guide__footer">
{ canGoBack && (
<IconButton
className="components-guide__back-button"
icon={ <BackButtonIcon /> }
onClick={ goBack }
>
{ __( 'Previous' ) }
</IconButton>
) }
<PageControl
currentPage={ currentPage }
numberOfPages={ numberOfPages }
setCurrentPage={ setCurrentPage }
/>
{ canGoForward && (
<IconButton
className="components-guide__forward-button"
icon={ <ForwardButtonIcon /> }
onClick={ goForward }
>
{ __( 'Next' ) }
</IconButton>
) }
{ ! canGoForward && (
<FinishButton
className="components-guide__finish-button"
onClick={ onFinish }
>
{ finishButtonText || __( 'Finish' ) }
</FinishButton>
) }
</div>

</div>

</Modal>
);
}
33 changes: 33 additions & 0 deletions packages/components/src/guide/page-control.js
@@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { times } from 'lodash';

/**
* WordPress dependencies
*/
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import IconButton from '../icon-button';
import { PageControlIcon } from './icons';

export default function PageControl( { currentPage, numberOfPages, setCurrentPage } ) {
return (
<ul className="components-guide__page-control" aria-label={ __( 'Guide controls' ) }>
{ times( numberOfPages, ( page ) => (
<li key={ page }>
<IconButton
key={ page }
icon={ <PageControlIcon isSelected={ page === currentPage } /> }
/* translators: %1$d: current page number %2$d: total number of pages */
aria-label={ sprintf( __( 'Page %1$d of %2$d' ), page + 1, numberOfPages ) }
onClick={ () => setCurrentPage( page ) }
/>
</li>
) ) }
</ul>
);
}
3 changes: 3 additions & 0 deletions packages/components/src/guide/page.js
@@ -0,0 +1,3 @@
export default function GuidePage( props ) {
return <div { ...props } />;
}
56 changes: 56 additions & 0 deletions packages/components/src/guide/stories/index.js
@@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { times } from 'lodash';
import { text, number } from '@storybook/addon-knobs';

/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import Button from '../../button';
import Guide from '../';
import GuidePage from '../page';

export default { title: 'Components|Guide', component: Guide };

const ModalExample = ( { numberOfPages, ...props } ) => {
const [ isOpen, setOpen ] = useState( false );

const openGuide = () => setOpen( true );
const closeGuide = () => setOpen( false );

const loremIpsum = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';

return (
<>
<Button isDefault onClick={ openGuide }>Open Guide</Button>
{ isOpen && (
<Guide { ...props } onFinish={ closeGuide }>
{ times( numberOfPages, ( page ) => (
<GuidePage key={ page }>
<h1>Page { page + 1 } of { numberOfPages }</h1>
<p>{ loremIpsum }</p>
</GuidePage>
) ) }
</Guide>
) }
</>
);
};

export const _default = () => {
const finishButtonText = text( 'finishButtonText', 'Finish' );
const numberOfPages = number( 'numberOfPages', 3, { range: true, min: 1, max: 10, step: 1 } );

const modalProps = {
finishButtonText,
numberOfPages,
};

return <ModalExample { ...modalProps } />;
};