diff --git a/changelog.txt b/changelog.txt index cdd9afc56cba3..4a23e8b0345a2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,30 @@ == Changelog == += 15.2.2 = + + + +## Changelog + +### Bug Fixes + +#### Layout +- Post Editor: Update postContentBlock check to see if the block is valid. ([48386](https://github.com/WordPress/gutenberg/pull/48386)) + + +## First time contributors + +The following PRs were merged by first time contributors: + + + +## Contributors + +The following contributors merged PRs in this release: + +@andrewserong + + = 15.2.1 = diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 2917c8577b07d..d9f017b60a99e 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -584,6 +584,15 @@ Post terms. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages - **Supports:** anchor, color (background, gradients, link, text), spacing (margin, padding), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** prefix, separator, suffix, term, textAlign +## Time To Read + +Show minutes required to finish reading the post. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/post-time-to-read)) + +- **Name:** core/post-time-to-read +- **Category:** theme +- **Supports:** ~~html~~, ~~multiple~~ +- **Attributes:** textAlign + ## Post Title Displays the title of a post, page, or any other content-type. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/post-title)) diff --git a/gutenberg.php b/gutenberg.php index c37c5ded254a2..a0a30fbe079d6 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.0 * Requires PHP: 5.6 - * Version: 15.2.1 + * Version: 15.2.2 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/blocks.php b/lib/blocks.php index add72e77062cb..ddd3d252c7570 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -90,6 +90,7 @@ function gutenberg_reregister_core_block_types() { 'post-featured-image.php' => 'core/post-featured-image', 'post-navigation-link.php' => 'core/post-navigation-link', 'post-terms.php' => 'core/post-terms', + 'post-time-to-read.php' => 'core/post-time-to-read', 'post-title.php' => 'core/post-title', 'query.php' => 'core/query', 'post-template.php' => 'core/post-template', diff --git a/lib/experimental/l10n.php b/lib/experimental/l10n.php new file mode 100644 index 0000000000000..8233bb8bb05a8 --- /dev/null +++ b/lib/experimental/l10n.php @@ -0,0 +1,139 @@ + '/<\/?[a-z][^>]*?>/i', + 'html_comment_regexp' => '//', + 'space_regexp' => '/ | /i', + 'html_entity_regexp' => '/&\S+?;/', + 'connector_regexp' => "/--|\x{2014}/u", + 'remove_regexp' => "/[\x{0021}-\x{0040}\x{005B}-\x{0060}\x{007B}-\x{007E}\x{0080}-\x{00BF}\x{00D7}\x{00F7}\x{2000}-\x{2BFF}\x{2E00}-\x{2E7F}]/u", + 'astral_regexp' => "/[\x{010000}-\x{10FFFF}]/u", + 'words_regexp' => '/\S\s+/u', + 'characters_excluding_spaces_regexp' => '/\S/u', + 'characters_including_spaces_regexp' => "/[^\f\n\r\t\v\x{00AD}\x{2028}\x{2029}]/u", + 'shortcodes' => array(), + ); + + $count = 0; + + if ( ! $text ) { + return $count; + } + + $settings = wp_parse_args( $settings, $defaults ); + + // If there are any shortcodes, add this as a shortcode regular expression. + if ( is_array( $settings['shortcodes'] ) && ! empty( $settings['shortcodes'] ) ) { + $settings['shortcodes_regexp'] = '/\\[\\/?(?:' . implode( '|', $settings['shortcodes'] ) . ')[^\\]]*?\\]/'; + } + + // Sanitize type to one of three possibilities: 'words', 'characters_excluding_spaces' or 'characters_including_spaces'. + if ( 'characters_excluding_spaces' !== $type && 'characters_including_spaces' !== $type ) { + $type = 'words'; + } + + $text .= "\n"; + + // Replace all HTML with a new-line. + $text = preg_replace( $settings['html_regexp'], "\n", $text ); + + // Remove all HTML comments. + $text = preg_replace( $settings['html_comment_regexp'], '', $text ); + + // If a shortcode regular expression has been provided use it to remove shortcodes. + if ( ! empty( $settings['shortcodes_regexp'] ) ) { + $text = preg_replace( $settings['shortcodes_regexp'], "\n", $text ); + } + + // Normalize non-breaking space to a normal space. + $text = preg_replace( $settings['space_regexp'], ' ', $text ); + + if ( 'words' === $type ) { + // Remove HTML Entities. + $text = preg_replace( $settings['html_entity_regexp'], '', $text ); + + // Convert connectors to spaces to count attached text as words. + $text = preg_replace( $settings['connector_regexp'], ' ', $text ); + + // Remove unwanted characters. + $text = preg_replace( $settings['remove_regexp'], '', $text ); + } else { + // Convert HTML Entities to "a". + $text = preg_replace( $settings['html_entity_regexp'], 'a', $text ); + + // Remove surrogate points. + $text = preg_replace( $settings['astral_regexp'], 'a', $text ); + } + + // Match with the selected type regular expression to count the items. + preg_match_all( $settings[ $type . '_regexp' ], $text, $matches ); + + if ( $matches ) { + return count( $matches[0] ); + } + + return $count; + } +} diff --git a/lib/load.php b/lib/load.php index 39b0446869791..03406015834ea 100644 --- a/lib/load.php +++ b/lib/load.php @@ -109,6 +109,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/blocks.php'; require __DIR__ . '/experimental/navigation-theme-opt-in.php'; require __DIR__ . '/experimental/kses.php'; +require __DIR__ . '/experimental/l10n.php'; // Fonts API. if ( ! class_exists( 'WP_Fonts' ) ) { diff --git a/package-lock.json b/package-lock.json index 74622d7a1a841..5f8c1da350247 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "15.2.1", + "version": "15.2.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -17373,6 +17373,7 @@ "@wordpress/server-side-render": "file:packages/server-side-render", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", + "@wordpress/wordcount": "file:packages/wordcount", "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.7.0", diff --git a/package.json b/package.json index ab2899eb4911e..de271b7d0fe3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "15.2.1", + "version": "15.2.2", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", diff --git a/packages/block-editor/src/components/block-styles/index.js b/packages/block-editor/src/components/block-styles/index.js index 7b9cbc7269198..9db5d4c87b2e0 100644 --- a/packages/block-editor/src/components/block-styles/index.js +++ b/packages/block-editor/src/components/block-styles/index.js @@ -99,7 +99,11 @@ function BlockStyles( { clientId, onSwitch = noop, onHoverClassName = noop } ) { } ) } { hoveredStyle && ! isMobileViewport && ( - +
styleItemHandler( null ) } diff --git a/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js b/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js index d582548b5d64e..46ec5839726e6 100644 --- a/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js +++ b/packages/block-editor/src/components/inspector-controls/block-support-tools-panel.js @@ -52,7 +52,6 @@ export default function BlockSupportToolsPanel( { children, group, label } ) { updateBlockAttributes( clientIds, newAttributes, true ); }, [ - cleanEmptyObject, getBlockAttributes, getMultiSelectedBlockClientIds, hasMultiSelection, diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index bf024b1fb758b..7034bb92ecffd 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -231,6 +231,10 @@ function ListViewBlock( { selectedClientIds, } ); + // Detect if there is a block in the canvas currently being edited and multi-selection is not happening. + const currentlyEditingBlockInCanvas = + isSelected && selectedClientIds.length === 1; + return (
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 34ba9fd5a2c8f..317b1d4fbad5e 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -83,6 +83,7 @@ import * as postFeaturedImage from './post-featured-image'; import * as postNavigationLink from './post-navigation-link'; import * as postTemplate from './post-template'; import * as postTerms from './post-terms'; +import * as postTimeToRead from './post-time-to-read'; import * as postTitle from './post-title'; import * as preformatted from './preformatted'; import * as pullquote from './pullquote'; @@ -197,6 +198,7 @@ const getAllBlocks = () => postTerms, postNavigationLink, postTemplate, + postTimeToRead, queryPagination, queryPaginationNext, queryPaginationNumbers, diff --git a/packages/block-library/src/post-time-to-read/block.json b/packages/block-library/src/post-time-to-read/block.json new file mode 100644 index 0000000000000..33cd4674d7753 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/block.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "__experimental": true, + "name": "core/post-time-to-read", + "title": "Time To Read", + "category": "theme", + "description": "Show minutes required to finish reading the post.", + "textdomain": "default", + "usesContext": [ "postId", "postType" ], + "attributes": { + "textAlign": { + "type": "string" + } + }, + "supports": { + "html": false, + "multiple": false + } +} diff --git a/packages/block-library/src/post-time-to-read/edit.js b/packages/block-library/src/post-time-to-read/edit.js new file mode 100644 index 0000000000000..b9092c69952b7 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/edit.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { _x, _n, sprintf } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import { + AlignmentControl, + BlockControls, + useBlockProps, +} from '@wordpress/block-editor'; +import { __unstableSerializeAndClean } from '@wordpress/blocks'; +import { useEntityProp, useEntityBlockEditor } from '@wordpress/core-data'; +import { count as wordCount } from '@wordpress/wordcount'; + +/** + * Average reading rate - based on average taken from + * https://irisreading.com/average-reading-speed-in-various-languages/ + * (Characters/minute used for Chinese rather than words). + */ +const AVERAGE_READING_RATE = 189; + +function PostTimeToReadEdit( { attributes, setAttributes, context } ) { + const { textAlign } = attributes; + const { postId, postType } = context; + + const [ contentStructure ] = useEntityProp( + 'postType', + postType, + 'content', + postId + ); + + const [ blocks ] = useEntityBlockEditor( 'postType', postType, { + id: postId, + } ); + + const minutesToReadString = useMemo( () => { + // Replicates the logic found in getEditedPostContent(). + let content; + if ( contentStructure instanceof Function ) { + content = contentStructure( { blocks } ); + } else if ( blocks ) { + // If we have parsed blocks already, they should be our source of truth. + // Parsing applies block deprecations and legacy block conversions that + // unparsed content will not have. + content = __unstableSerializeAndClean( blocks ); + } else { + content = contentStructure; + } + + /* + * translators: If your word count is based on single characters (e.g. East Asian characters), + * enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'. + * Do not translate into your own language. + */ + const wordCountType = _x( + 'words', + 'Word count type. Do not translate!' + ); + + const minutesToRead = Math.max( + 1, + Math.round( + wordCount( content, wordCountType ) / AVERAGE_READING_RATE + ) + ); + + return sprintf( + /* translators: %d is the number of minutes the post will take to read. */ + _n( '%d minute', '%d minutes', minutesToRead ), + minutesToRead + ); + }, [ contentStructure, blocks ] ); + + const blockProps = useBlockProps( { + className: classnames( { + [ `has-text-align-${ textAlign }` ]: textAlign, + } ), + } ); + + return ( + <> + + { + setAttributes( { textAlign: nextAlign } ); + } } + /> + +

{ minutesToReadString }

+ + ); +} + +export default PostTimeToReadEdit; diff --git a/packages/block-library/src/post-time-to-read/icon.js b/packages/block-library/src/post-time-to-read/icon.js new file mode 100644 index 0000000000000..56b6b2b182fc2 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/icon.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export default ( + + + +); diff --git a/packages/block-library/src/post-time-to-read/index.js b/packages/block-library/src/post-time-to-read/index.js new file mode 100644 index 0000000000000..95b379f55f0b3 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/index.js @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import icon from './icon'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + icon, + edit, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/post-time-to-read/index.php b/packages/block-library/src/post-time-to-read/index.php new file mode 100644 index 0000000000000..07761e5e75904 --- /dev/null +++ b/packages/block-library/src/post-time-to-read/index.php @@ -0,0 +1,62 @@ +context['postId'] ) ) { + return ''; + } + + $content = get_the_content(); + + /* + * Average reading rate - based on average taken from + * https://irisreading.com/average-reading-speed-in-various-languages/ + * (Characters/minute used for Chinese rather than words). + */ + $average_reading_rate = 189; + + $word_count_type = wp_get_word_count_type(); + + $minutes_to_read = max( 1, (int) round( wp_word_count( $content, $word_count_type ) / $average_reading_rate ) ); + + $minutes_to_read_string = sprintf( + /* translators: %d is the number of minutes the post will take to read. */ + _n( '%d minute', '%d minutes', $minutes_to_read ), + $minutes_to_read + ); + + $align_class_name = empty( $attributes['textAlign'] ) ? '' : "has-text-align-{$attributes['textAlign']}"; + + $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $align_class_name ) ); + + return sprintf( + '

%2$s

', + $wrapper_attributes, + $minutes_to_read_string + ); +} + +/** + * Registers the `core/post-time-to-read` block on the server. + */ +function register_block_core_post_time_to_read() { + register_block_type_from_metadata( + __DIR__ . '/post-time-to-read', + array( + 'render_callback' => 'render_block_core_post_time_to_read', + ) + ); +} +add_action( 'init', 'register_block_core_post_time_to_read' ); diff --git a/packages/block-library/src/site-logo/editor.scss b/packages/block-library/src/site-logo/editor.scss index e13fc68abc4c3..0940bf985cb90 100644 --- a/packages/block-library/src/site-logo/editor.scss +++ b/packages/block-library/src/site-logo/editor.scss @@ -33,10 +33,10 @@ // Provide special styling for the placeholder. // @todo: this particular minimal style of placeholder could be componentized further. .wp-block-site-logo.wp-block-site-logo { - // Match the default logo size. + &.is-default-size .components-placeholder { - height: 120px; - width: 120px; + height: 60px; + width: 60px; } // Inherit radius. diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index cdf20e557f242..cb70d96e7c3f8 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -28,7 +28,8 @@ { "path": "../icons" }, { "path": "../keycodes" }, { "path": "../primitives" }, - { "path": "../url" } + { "path": "../url" }, + { "path": "../wordcount" } ], "include": [ "src/**/*.ts", "src/**/*.tsx" ] } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 267c23bc8fcdf..4dbf7dd42a8f1 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -9,6 +9,7 @@ ### Internal - `CircularOptionPicker`: Convert to TypeScript ([#47937](https://github.com/WordPress/gutenberg/pull/47937)). +- `TabPanel`: Improve unit test in preparation for controlled component updates ([#48086](https://github.com/WordPress/gutenberg/pull/48086)). ## 23.4.0 (2023-02-15) diff --git a/packages/components/src/tab-panel/stories/index.tsx b/packages/components/src/tab-panel/stories/index.tsx index 7846fe0bdbff2..07c076e1ce621 100644 --- a/packages/components/src/tab-panel/stories/index.tsx +++ b/packages/components/src/tab-panel/stories/index.tsx @@ -3,16 +3,25 @@ */ import type { ComponentMeta, ComponentStory } from '@storybook/react'; +/** + * WordPress dependencies + */ +import { link, more, wordpress } from '@wordpress/icons'; + /** * Internal dependencies */ import TabPanel from '..'; +import Popover from '../../popover'; +import { Provider as SlotFillProvider } from '../../slot-fill'; const meta: ComponentMeta< typeof TabPanel > = { title: 'Components/TabPanel', component: TabPanel, parameters: { + actions: { argTypesRegex: '^on.*' }, controls: { expanded: true }, + docs: { source: { state: 'open' } }, }, }; export default meta; @@ -55,3 +64,35 @@ DisabledTab.args = { }, ], }; + +const SlotFillTemplate: ComponentStory< typeof TabPanel > = ( props ) => { + return ( + + + { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } + + + ); +}; + +export const WithTabIconsAndTooltips = SlotFillTemplate.bind( {} ); +WithTabIconsAndTooltips.args = { + children: ( tab ) =>

Selected tab: { tab.title }

, + tabs: [ + { + name: 'tab1', + title: 'Tab 1', + icon: wordpress, + }, + { + name: 'tab2', + title: 'Tab 2', + icon: link, + }, + { + name: 'tab3', + title: 'Tab 3', + icon: more, + }, + ], +}; diff --git a/packages/components/src/tab-panel/test/index.tsx b/packages/components/src/tab-panel/test/index.tsx index 83f728f962924..16f88ee8a41e9 100644 --- a/packages/components/src/tab-panel/test/index.tsx +++ b/packages/components/src/tab-panel/test/index.tsx @@ -1,15 +1,20 @@ /** * External dependencies */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { wordpress, category, media } from '@wordpress/icons'; + /** * Internal dependencies */ import TabPanel from '..'; - -const setupUser = () => userEvent.setup(); +import Popover from '../../popover'; +import { Provider as SlotFillProvider } from '../../slot-fill'; const TABS = [ { @@ -33,7 +38,14 @@ const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } ); let originalGetClientRects: () => DOMRectList; -describe( 'TabPanel', () => { +describe.each( [ + [ 'uncontrolled', TabPanel ], + // The controlled component tests will be added once we certify the + // uncontrolled component's behaviour on trunk. + // [ 'controlled', TabPanel ], +] )( 'TabPanel %s', ( ...modeAndComponent ) => { + const [ , Component ] = modeAndComponent; + beforeAll( () => { originalGetClientRects = window.HTMLElement.prototype.getClientRects; // Mocking `getClientRects()` is necessary to pass a check performed by @@ -49,295 +61,589 @@ describe( 'TabPanel', () => { window.HTMLElement.prototype.getClientRects = originalGetClientRects; } ); - it( 'should render a tabpanel, and clicking should change tabs', async () => { - const user = setupUser(); - const panelRenderFunction = jest.fn(); - const mockOnSelect = jest.fn(); - - render( - - ); - - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) - ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( - screen.getByRole( 'tabpanel', { name: 'Beta' } ) - ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 1 ] ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) - ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - } ); + describe( 'Accessibility and semantics', () => { + test( 'should use the correct aria attributes', () => { + const panelRenderFunction = jest.fn(); - it( 'should render with a tab initially selected by prop initialTabIndex', () => { - render( - undefined } - /> - ); - const selectedTab = screen.getByRole( 'tab', { selected: true } ); - expect( selectedTab ).toHaveTextContent( 'Beta' ); - } ); + render( + + ); - it( 'should apply the `activeClass` to the selected tab', async () => { - const user = setupUser(); - const activeClass = 'my-active-tab'; - - render( - undefined } - /> - ); - expect( getSelectedTab() ).toHaveClass( activeClass ); - screen - .getAllByRole( 'tab', { selected: false } ) - .forEach( ( unselectedTab ) => { - expect( unselectedTab ).not.toHaveClass( activeClass ); - } ); - - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - - expect( getSelectedTab() ).toHaveClass( activeClass ); - screen - .getAllByRole( 'tab', { selected: false } ) - .forEach( ( unselectedTab ) => { - expect( unselectedTab ).not.toHaveClass( activeClass ); - } ); - } ); + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const selectedTabPanel = screen.getByRole( 'tabpanel' ); - it( "should apply the tab's `className` to the tab button", () => { - render( undefined } /> ); - - expect( screen.getByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( - 'alpha-class' - ); - expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( - 'beta-class' - ); - expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( - 'gamma-class' - ); - } ); + expect( tabList ).toBeVisible(); + expect( tabList ).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); - it( 'should select `initialTabName` if defined', () => { - const mockOnSelect = jest.fn(); - - render( - undefined } - onSelect={ mockOnSelect } - /> - ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - } ); + expect( allTabs ).toHaveLength( TABS.length ); - it( 'waits for the tab with the `initialTabName` to become present in the `tabs` array before selecting it', () => { - const mockOnSelect = jest.fn(); - - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // There should be no selected tab. - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); - } ); + // The selected `tab` aria-controls the active `tabpanel`, + // which is `aria-labelledby` the selected `tab`. + expect( selectedTabPanel ).toBeVisible(); + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + selectedTabPanel.getAttribute( 'id' ) + ); + expect( selectedTabPanel ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); - it( 'should disable the tab when `disabled` is true', async () => { - const user = setupUser(); - const mockOnSelect = jest.fn(); - - render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( screen.getByRole( 'tab', { name: 'Delta' } ) ).toHaveAttribute( - 'aria-disabled', - 'true' - ); - - // onSelect gets called on the initial render. - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - - // onSelect should not be called since the disabled tab is highlighted, but not selected. - await user.keyboard( '[ArrowLeft]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - } ); + test( 'should display a tooltip when hovering tabs provided with an icon', async () => { + const user = userEvent.setup(); - it( 'should select the first enabled tab when the inital tab is disabled', () => { - const mockOnSelect = jest.fn(); - - render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); + const panelRenderFunction = jest.fn(); - it( 'should select the first enabled tab when the currently selected becomes disabled', () => { - const mockOnSelect = jest.fn(); + const TABS_WITH_ICON = [ + { ...TABS[ 0 ], icon: wordpress }, + { ...TABS[ 1 ], icon: category }, + { ...TABS[ 2 ], icon: media }, + ]; - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); + render( + // In order for the tooltip to display properly, there needs to be + // `Popover.Slot` in which the `Popover` renders outside of the + // `TabPanel` component, otherwise the tooltip renders inline. + + + { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } + + + ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + const allTabs = screen.getAllByRole( 'tab' ); - rerender( - { - if ( tab.name === 'alpha' ) { - return { ...tab, disabled: true }; - } - return tab; - } ) } - children={ () => undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + for ( let i = 0; i < allTabs.length; i++ ) { + expect( + screen.queryByText( TABS_WITH_ICON[ i ].title ) + ).not.toBeInTheDocument(); + + await user.hover( allTabs[ i ] ); + + await waitFor( () => + expect( + screen.getByText( TABS_WITH_ICON[ i ].title ) + ).toBeVisible() + ); + + await user.unhover( allTabs[ i ] ); + } + } ); + + test( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => { + const user = userEvent.setup(); + + const mockOnSelect = jest.fn(); + const panelRenderFunction = jest.fn(); + + const TABS_WITH_ICON = [ + { ...TABS[ 0 ], icon: wordpress }, + { ...TABS[ 1 ], icon: category }, + { ...TABS[ 2 ], icon: media }, + ]; + + render( + // In order for the tooltip to display properly, there needs to be + // `Popover.Slot` in which the `Popover` renders outside of the + // `TabPanel` component, otherwise the tooltip renders inline. + + + { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } + + + ); + + expect( getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + + // Tab to focus the tablist. Make sure alpha is focused, and that the + // corresponding tooltip is shown. + expect( screen.queryByText( 'Alpha' ) ).not.toBeInTheDocument(); + await user.keyboard( '[Tab]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); + await expect( getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure beta is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); + await expect( getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure gamma is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Gamma' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); + await expect( getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure beta is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowLeft]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); + await expect( getSelectedTab() ).toHaveFocus(); + } ); } ); - describe( 'fallbacks when new tab list invalidates current selection', () => { - it( 'should select `initialTabName` if defined', async () => { - const user = setupUser(); + describe( 'Without `initialTabName`', () => { + it( 'should render first tab', async () => { + const panelRenderFunction = jest.fn(); + + render( + + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); + } ); + + it( 'should fall back to first enabled tab if the active tab is removed', async () => { const mockOnSelect = jest.fn(); + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + } ); + describe( 'With `initialTabName`', () => { + it( 'should render the tab set by initialTabName prop', () => { + render( + undefined } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should not select a tab when `initialTabName` does not match any known tab', () => { + render( + undefined } + /> + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + + it( 'should not change tabs when initialTabName is changed', () => { const { rerender } = render( - undefined } + /> + ); + + rerender( + undefined } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } onSelect={ mockOnSelect } /> ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); rerender( - undefined } onSelect={ mockOnSelect } /> ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); - it( 'should select first tab if `initialTabName` not defined', async () => { - const user = setupUser(); + it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', () => { const mockOnSelect = jest.fn(); const { rerender } = render( - undefined } onSelect={ mockOnSelect } /> ); - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); rerender( - undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // There should be no selected tab yet. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); + } ); + } ); + + describe( 'Disabled Tab', () => { + it( 'should disable the tab when `disabled` is `true`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // onSelect should not be called since the disabled tab is + // highlighted, but not selected. + await user.keyboard( '[ArrowLeft]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should select first enabled tab when the initial tab is disabled', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + { + if ( tab.name !== 'alpha' ) { + return tab; + } + return { ...tab, disabled: true }; + } ) } children={ () => undefined } onSelect={ mockOnSelect } /> ); + + // As alpha (first tab) is disabled, + // the first enabled tab should be gamma. expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Re-enable all tabs + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + { + if ( tab.name === 'gamma' ) { + return tab; + } + return { ...tab, disabled: true }; + } ) } + initialTabName="beta" + children={ () => undefined } + onSelect={ mockOnSelect } + /> + ); + + // As alpha (first tab), and beta (the initial tab), are both + // disabled the first enabled tab should be gamma. + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Re-enable all tabs + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should select the first enabled tab when the selected tab becomes disabled', () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + { + if ( tab.name === 'alpha' ) { + return { ...tab, disabled: true }; + } + return tab; + } ) } + children={ () => undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + } ); + + it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); } ); } ); - describe( 'tab activation', () => { - it( 'defaults to automatic tab activation', async () => { - const user = setupUser(); + describe( 'Tab Activation', () => { + it( 'defaults to automatic tab activation (pointer clicks)', async () => { + const user = userEvent.setup(); + const panelRenderFunction = jest.fn(); const mockOnSelect = jest.fn(); render( - + ); + + // Alpha is the initially selected tab + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on Beta, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Beta' } ) + ).toBeInTheDocument(); + expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 1 ] ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Click on Alpha, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'defaults to automatic tab activation (arrow keys)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } onSelect={ mockOnSelect } @@ -347,42 +653,219 @@ describe( 'TabPanel', () => { // onSelect gets called on the initial render. expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - // Click on Alpha, make sure Alpha is selected - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + // Tab to focus the tablist. Make sure alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + await expect( getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'wraps around the last/first tab when using arrow keys', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Navigate backwards with arrow keys and make sure that the Gamma tab + // (the last tab) is selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - // Navigate forward with arrow keys, - // make sure Beta is selected automatically. + // Navigate forward with arrow keys. Make sure alpha (the first tab) is + // selected automatically. await user.keyboard( '[ArrowRight]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Press the arrow up key, nothing happens. + await user.keyboard( '[ArrowUp]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Press the arrow down key, nothing happens + await user.keyboard( '[ArrowDown]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Change orientation to `vertical`. When the orientation is vertical, + // left/right arrow keys are replaced by up/down arrow keys. + rerender( + undefined } + onSelect={ mockOnSelect } + orientation="vertical" + /> + ); + + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + // Make sure alpha is still focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // Navigate forward with arrow keys, - // make sure Gamma (last tab) is selected automatically. - await user.keyboard( '[ArrowRight]' ); + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - // Navigate forward with arrow keys, - // make sure Alpha (first tab) is selected automatically. - await user.keyboard( '[ArrowRight]' ); + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); - // Navigate backwards with arrow keys, - // make sure Gamma (last tab) is selected automatically - await user.keyboard( '[ArrowLeft]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 6 ); + it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Press the right arrow key three times. Since the delta tab is disabled: + // - it won't be selected. The gamma tab will be selected instead, since + // it was the tab that was last selected before delta. Therefore, the + // `mockOnSelect` function gets called only twice (and not three times) + // - it will receive focus, when using arrow keys + await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate backwards with arrow keys. The gamma tab receives focus. + await user.keyboard( '[ArrowLeft]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + + // Click on on the disabled tab. Compared to using arrow keys to move the + // focus, disabled tabs ignore pointer clicks — and therefore, they don't + // receive focus, nor they cause the `mockOnSelect` function to fire. + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); } ); it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { - const user = setupUser(); + const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( - undefined } onSelect={ mockOnSelect } @@ -393,35 +876,84 @@ describe( 'TabPanel', () => { // onSelect gets called on the initial render. expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - // Click on Alpha, make sure Alpha is selected + // Click on Alpha and make sure it is selected. await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - // Navigate forward with arrow keys. - // Make sure Beta is focused, but that the tab selection happens only when - // pressing the spacebar or the enter key. + // Navigate forward with arrow keys. Make sure Beta is focused, but + // that the tab selection happens only when pressing the spacebar + // or enter key. await user.keyboard( '[ArrowRight]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); + await user.keyboard( '[Enter]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - // Navigate forward with arrow keys. - // Make sure Gamma (last tab) is focused, but that the tab selection - // happens only when pressing the spacebar or the enter key. + // Navigate forward with arrow keys. Make sure Gamma (last tab) is + // focused, but that tab selection happens only when pressing the + // spacebar or enter key. await user.keyboard( '[ArrowRight]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); + await user.keyboard( '[Space]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + } ); + } ); + + describe( 'Tab Attributes', () => { + it( "should apply the tab's `className` to the tab button", () => { + render( undefined } /> ); - // No need to test the "wrap-around" behavior, as it's being tested in the - // "automatic tab activation" test above. + expect( screen.getByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( + 'alpha-class' + ); + expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( + 'beta-class' + ); + expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( + 'gamma-class' + ); + } ); + + it( 'should apply the `activeClass` to the selected tab', async () => { + const user = userEvent.setup(); + const activeClass = 'my-active-tab'; + + render( + undefined } + /> + ); + + // Make sure that only the selected tab has the active class + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( getSelectedTab() ).toHaveClass( activeClass ); + screen + .getAllByRole( 'tab', { selected: false } ) + .forEach( ( unselectedTab ) => { + expect( unselectedTab ).not.toHaveClass( activeClass ); + } ); + + // Click the 'Beta' tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + // Make sure that only the selected tab has the active class + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( getSelectedTab() ).toHaveClass( activeClass ); + screen + .getAllByRole( 'tab', { selected: false } ) + .forEach( ( unselectedTab ) => { + expect( unselectedTab ).not.toHaveClass( activeClass ); + } ); } ); } ); } ); diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/navigation.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/navigation.test.js.snap deleted file mode 100644 index af00b7f62f19c..0000000000000 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/navigation.test.js.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Navigation Creating and restarting converts uncontrolled inner blocks to an entity when modifications are made to the blocks 1`] = `""`; - -exports[`Navigation Placeholder menu selector actions allows a navigation block to be created from existing menus 1`] = ` -" - - - - - - - - - - - - - - - - - - - - - - - -" -`; - -exports[`Navigation Placeholder menu selector actions creates an empty navigation block when the selected existing menu is also empty 1`] = `""`; - -exports[`Navigation allows an empty navigation block to be created and manually populated using a mixture of internal and external links 1`] = ` -" - -" -`; - -exports[`Navigation allows pages to be created from the navigation block and their links added to menu 1`] = `""`; - -exports[`Navigation encodes URL when create block if needed 1`] = ` -" - -" -`; diff --git a/packages/e2e-tests/specs/editor/blocks/navigation.test.js b/packages/e2e-tests/specs/editor/blocks/navigation.test.js deleted file mode 100644 index 55076bd7180de..0000000000000 --- a/packages/e2e-tests/specs/editor/blocks/navigation.test.js +++ /dev/null @@ -1,1723 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickButton, - clickOnMoreMenuItem, - createJSONResponse, - createNewPost, - createMenu as createClassicMenu, - deleteAllMenus as deleteAllClassicMenus, - insertBlock, - setUpResponseMocking, - pressKeyWithModifier, - saveDraft, - showBlockToolbar, - openPreviewPage, - ensureSidebarOpened, - __experimentalRest as rest, - __experimentalBatch as batch, - publishPost, - createUser, - loginUser, - deleteUser, - switchUserToAdmin, - clickBlockToolbarButton, - openListView, - getListViewBlocks, -} from '@wordpress/e2e-test-utils'; -import { addQueryArgs } from '@wordpress/url'; - -/** - * Internal dependencies - */ -import menuItemsFixture from '../fixtures/menu-items-request-fixture.json'; - -const POSTS_ENDPOINT = '/wp/v2/posts'; -const PAGES_ENDPOINT = '/wp/v2/pages'; -const DRAFT_PAGES_ENDPOINT = [ PAGES_ENDPOINT, { status: 'draft' } ]; -const NAVIGATION_MENUS_ENDPOINT = '/wp/v2/navigation'; -// todo: consolidate with logic found in navigation-editor tests -// https://github.com/WordPress/gutenberg/blob/trunk/packages/e2e-tests/specs/experiments/navigation-editor.test.js#L71 -const REST_PAGES_ROUTES = [ - '/wp/v2/pages', - `rest_route=${ encodeURIComponent( '/wp/v2/pages' ) }`, -]; -let uniqueId = 0; - -/** - * Determines if a given URL matches any of a given collection of - * routes (expressed as substrings). - * - * @param {string} reqUrl the full URL to be tested for matches. - * @param {Array} routes array of strings to match against the URL. - */ -function matchUrlToRoute( reqUrl, routes ) { - return routes.some( ( route ) => reqUrl.includes( route ) ); -} - -function getEndpointMocks( matchingRoutes, responsesByMethod ) { - return [ 'GET', 'POST', 'DELETE', 'PUT' ].reduce( ( mocks, restMethod ) => { - if ( responsesByMethod[ restMethod ] ) { - return [ - ...mocks, - { - match: ( request ) => - matchUrlToRoute( request.url(), matchingRoutes ) && - request.method() === restMethod, - onRequestMatch: createJSONResponse( - responsesByMethod[ restMethod ] - ), - }, - ]; - } - - return mocks; - }, [] ); -} - -function getPagesMocks( responsesByMethod ) { - return getEndpointMocks( REST_PAGES_ROUTES, responsesByMethod ); -} - -async function mockSearchResponse( items ) { - const mappedItems = items.map( ( { title, slug }, index ) => ( { - id: index + 1, - subtype: 'page', - title, - type: 'post', - url: `https://this/is/a/test/search/${ slug }`, - } ) ); - await setUpResponseMocking( [ - { - match: ( request ) => - request.url().includes( `rest_route` ) && - request.url().includes( `search` ), - onRequestMatch: createJSONResponse( mappedItems ), - }, - ...getPagesMocks( { - GET: [ - { - type: 'page', - id: 1, - link: 'https://example.com/1', - title: { - rendered: 'My page', - }, - }, - ], - } ), - ] ); -} - -async function forceSelectNavigationBlock() { - const navBlock = await waitForBlock( 'Navigation' ); - - if ( ! navBlock ) { - return; - } - - await page.evaluate( () => { - const blocks = wp.data.select( 'core/block-editor' ).getBlocks(); - const navigationBlock = blocks.find( - ( block ) => block.name === 'core/navigation' - ); - - if ( ! navigationBlock ) { - return; - } - - return wp.data - .dispatch( 'core/block-editor' ) - .selectBlock( navigationBlock?.clientId, 0 ); - } ); -} - -/** - * Interacts with the LinkControl to perform a search and select a returned suggestion - * - * @param {Object} link link object to be tested - * @param {string} link.url What will be typed in the search input - * @param {string} link.label What the resulting label will be in the creating Link Block after the block is created. - * @param {string} link.type What kind of suggestion should be clicked, ie. 'url', 'create', or 'entity' - */ -async function updateActiveNavigationLink( { url, label, type } ) { - const typeClasses = { - create: 'block-editor-link-control__search-create', - entity: 'is-entity', - url: 'is-url', - }; - - if ( url ) { - const input = await page.waitForSelector( - 'input[placeholder="Search or type url"]' - ); - await input.type( url ); - - const suggestionPath = `//button[contains(@class, 'block-editor-link-control__search-item') and contains(@class, '${ typeClasses[ type ] }') and contains(., "${ url }")]`; - - // Wait for the autocomplete suggestion item to appear. - await page.waitForXPath( suggestionPath ); - // Set the suggestion. - const suggestion = await page.waitForXPath( suggestionPath ); - - // Select it (so we're clicking the right one, even if it's further down the list). - await suggestion.click(); - } - - if ( label ) { - // Wait for rich text editor input to be focused before we start typing the label. - await page.waitForSelector( ':focus.rich-text' ); - - // With https://github.com/WordPress/gutenberg/pull/19686, we're auto-selecting the label if the label is URL-ish. - // In this case, it means we have to select and delete the label if it's _not_ the url. - if ( label !== url ) { - // Ideally this would be `await pressKeyWithModifier( 'primary', 'a' )` - // to select all text like other tests do. - // Unfortunately, these tests don't seem to pass on Travis CI when - // using that approach, while using `Home` and `End` they do pass. - await page.keyboard.press( 'Home' ); - await pressKeyWithModifier( 'shift', 'End' ); - await page.keyboard.press( 'Backspace' ); - } - - await page.keyboard.type( label ); - } -} - -async function selectClassicMenu( optionText ) { - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const theOption = await page.waitForXPath( - '//button[contains(., "' + optionText + '")]' - ); - await theOption.click(); - - await page.waitForResponse( - ( response ) => - response.url().includes( 'menu-items' ) && response.status() === 200 - ); -} - -/** - * Delete all items for the given REST resources using the REST API. - * - * @param {*} endpoints The endpoints of the resources to delete. - */ -async function deleteAll( endpoints ) { - for ( const endpoint of endpoints ) { - const defaultArgs = { per_page: -1 }; - const isArrayEndpoint = Array.isArray( endpoint ); - const path = isArrayEndpoint ? endpoint[ 0 ] : endpoint; - const args = isArrayEndpoint - ? { ...defaultArgs, ...endpoint[ 1 ] } - : defaultArgs; - - const items = await rest( { - path: addQueryArgs( path, args ), - } ); - - for ( const item of items ) { - await rest( { - method: 'DELETE', - path: `${ path }/${ item.id }?force=true`, - } ); - } - } -} - -/** - * Replace unique ids in nav block content, since these won't be consistent - * between test runs. - * - * @param {string} content HTML block content, either raw or rendered. - * - * @return {string} HTML block content with stripped ids - */ -function stripPageIds( content ) { - return content - .replace( /page_id=\d+/gm, 'page_id=[number]' ) - .replace( /"id":\d+/gm, '"id":[number]' ); -} - -/** - * Check navigation block content by fetching the navigation menu. - * - * @return {string} Menu content. - */ -async function getNavigationMenuRawContent() { - const menuRef = await page.evaluate( () => { - const blocks = wp.data.select( 'core/block-editor' ).getBlocks(); - const navigationBlock = blocks.find( - ( block ) => block.name === 'core/navigation' - ); - - return navigationBlock.attributes.ref; - } ); - - if ( ! menuRef ) { - throw 'getNavigationMenuRawContent was unable to find a ref attribute on the first navigation block'; - } - - const response = await rest( { - method: 'GET', - path: `/wp/v2/navigation/${ menuRef }?context=edit`, - } ); - - return stripPageIds( response.content.raw ); -} - -async function waitForBlock( blockName ) { - const blockSelector = `[aria-label="Editor content"][role="region"] [aria-label="Block: ${ blockName }"]`; - - // Wait for a Submenu block before making assertion. - return page.waitForSelector( blockSelector ); -} - -// Disable reason - these tests are to be re-written. -// Skipped temporarily due to issues with GH actions: https://wordpress.slack.com/archives/C02QB2JS7/p1661331673166269. -// eslint-disable-next-line jest/no-disabled-tests -describe.skip( 'Navigation', () => { - const contributorUsername = `contributoruser_${ ++uniqueId }`; - let contributorPassword; - - beforeAll( async () => { - // Creation of the contributor user **MUST** be at the top level describe block - // otherwise this test will become unstable. This action only happens once - // so there is no huge performance hit. - contributorPassword = await createUser( contributorUsername, { - role: 'contributor', - } ); - } ); - - beforeEach( async () => { - await deleteAll( [ - POSTS_ENDPOINT, - PAGES_ENDPOINT, - DRAFT_PAGES_ENDPOINT, - NAVIGATION_MENUS_ENDPOINT, - ] ); - await deleteAllClassicMenus(); - } ); - - afterEach( async () => { - await setUpResponseMocking( [] ); - } ); - - afterAll( async () => { - await deleteAll( [ - POSTS_ENDPOINT, - PAGES_ENDPOINT, - DRAFT_PAGES_ENDPOINT, - NAVIGATION_MENUS_ENDPOINT, - ] ); - await deleteAllClassicMenus(); - - // As per the creation in the beforeAll() above, this - // action must be done at the root level describe() block. - await deleteUser( contributorUsername ); - } ); - - describe( 'loading states', () => { - it( 'does not show a loading indicator if there is no ref to a Navigation post and Nav Menus have loaded', async () => { - await createNewPost(); - - // Insert an empty block to trigger resolution of Nav Menu items. - await insertBlock( 'Navigation' ); - await waitForBlock( 'Navigation' ); - - await page.waitForXPath( "//button[text()='Select Menu']" ); - - // Now we have Nav Menu items resolved. Continue to assert. - await clickOnMoreMenuItem( 'Code editor' ); - - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - - // Simulate block behaviour when loading a page containing an unconfigured Nav block - // that is not selected. - await codeEditorInput.click(); - const markup = ''; - await page.keyboard.type( markup ); - await clickButton( 'Exit code editor' ); - - // Wait for block to render... - const navBlock = await waitForBlock( 'Navigation' ); - - // Test specifically for the primary loading indicator because a spinner also exists - // in the hidden Placeholder component when it is loading. - const loadingSpinner = await navBlock.$( - '.wp-block-navigation__loading-indicator.components-spinner' - ); - - // We should not see the loading state if the block has not been configured and is empty. - expect( loadingSpinner ).toBeNull(); - } ); - - // Skip reason: This test is quite flaky recently. - // See https://github.com/WordPress/gutenberg/issues/39231. - // eslint-disable-next-line jest/no-disabled-tests - it.skip( 'shows a loading indicator whilst ref resolves to Navigation post items', async () => { - const testNavId = 1; - - let resolveNavigationRequest; - - // Mock the request for the single Navigation post in order to fully - // control the resolution of the request. This will enable the ability - // to assert on how the UI responds during the API resolution without - // relying on variable factors such as network conditions. - await setUpResponseMocking( [ - { - match: ( request ) => { - return decodeURIComponent( request.url() ).includes( - `navigation/` - ); - }, - onRequestMatch: ( request ) => { - // The Promise simulates a REST API request whose resolultion - // the test has full control over. - return new Promise( ( resolve ) => { - // Assign the resolution function to the var in the - // upper scope to afford control over resolution. - resolveNavigationRequest = resolve; - - // Call request.continue() is required to fully resolve the mock. - } ).then( () => request.continue() ); - }, - }, - ] ); - /* -Expected mock function not to be called but it was called with: ["POST", "http://localhost:8889/wp-admin/admin-ajax.php", "http://localhost:8889/wp-admin/admin-ajax.php"],["GET", "http://localhost:8889/wp-admin/post-new.php", "http://localhost:8889/wp-admin/post-new.php"],["GET", "http://localhost:8889/wp-includes/js/mediaelement/mediaelementplayer-legacy.min.css?ver=4.2.16", "http://localhost:8889/wp-includes/js/mediaelement/mediaelementplayer-legacy.min.css?ver=4.2.16"],["GET", "http://localhost:8889/wp-includes/js/mediaelement/wp-mediaelement.min.css?ver=6.1-alpha-53506", "http://localhost:8889/wp-includes/js/mediaelement/wp-mediaelement.min.css?ver=6.1-alpha-53506"],["GET", "http://localhost:8889/wp-includes/js/imgareaselect/imgareaselect.css?ver=0.9.8", "http://localhost:8889/wp-includes/js/imgareaselect/imgareaselect.css?ver=0.9.8"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/components/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/components/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-editor/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-editor/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/nux/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/nux/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/reusable-blocks/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/reusable-blocks/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/editor/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/editor/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/reset.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/reset.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/edit-post/classic.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/edit-post/classic.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/editor.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/editor.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/edit-post/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/edit-post/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-directory/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-directory/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/format-library/style.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/format-library/style.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/themes/twentytwentyone/assets/css/custom-color-overrides.css?ver=1.6", "http://localhost:8889/wp-content/themes/twentytwentyone/assets/css/custom-color-overrides.css?ver=1.6"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/theme.css?ver=1655290402", "http://localhost:8889/wp-content/plugins/gutenberg/build/block-library/theme.css?ver=1655290402"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/blob/index.min.js?ver=bccaf46e493181a8db9a", "http://localhost:8889/wp-content/plugins/gutenberg/build/blob/index.min.js?ver=bccaf46e493181a8db9a"],["GET", "http://localhost:8889/wp-content/plugins/gutenberg/build/autop/index.min.js?ver=b1a2f86387be4fa46f89", "http://loca - */ - await createNewPost(); - await clickOnMoreMenuItem( 'Code editor' ); - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - await codeEditorInput.click(); - - // The ID used in this `ref` is that which we mock in the request - // above to ensure we can control the resolution of the Navigation post. - const markup = ``; - await page.keyboard.type( markup ); - await clickButton( 'Exit code editor' ); - - const navBlock = await waitForBlock( 'Navigation' ); - - // Check for the spinner to be present whilst loading. - await navBlock.waitForSelector( '.components-spinner' ); - - // Resolve the controlled mocked API request. - if ( typeof resolveNavigationRequest === 'function' ) { - resolveNavigationRequest(); - } - } ); - - it( 'shows a loading indicator whilst empty Navigation menu is being created', async () => { - const testNavId = 1; - - let resolveNavigationRequest; - - // Mock the request for the single Navigation post in order to fully - // control the resolution of the request. This will enable the ability - // to assert on how the UI responds during the API resolution without - // relying on variable factors such as network conditions. - await setUpResponseMocking( [ - { - match: ( request ) => - request.url().includes( `rest_route` ) && - request.url().includes( `navigation` ) && - request.url().includes( `${ testNavId }?` ), - onRequestMatch: ( request ) => { - // The Promise simulates a REST API request whose resolultion - // the test has full control over. - return new Promise( ( resolve ) => { - // Assign the resolution function to the var in the - // upper scope to afford control over resolution. - resolveNavigationRequest = resolve; - - // Call request.continue() is required to fully resolve the mock. - } ).then( () => request.continue() ); - }, - }, - ] ); - - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navBlock = await waitForBlock( 'Navigation' ); - - // Create empty Navigation block with no items - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - // Check for the spinner to be present whilst loading. - await navBlock.waitForSelector( '.components-spinner' ); - - // Resolve the controlled mocked API request. - if ( typeof resolveNavigationRequest === 'function' ) { - resolveNavigationRequest(); - } - } ); - } ); - - describe( 'Placeholder', () => { - describe( 'fallback states', () => { - it( 'shows page list on insertion of block', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - await waitForBlock( 'Page List' ); - } ); - - it( 'shows placeholder preview when block with no menu items is not selected', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - // Wait for Navigation creation to complete. - await page.waitForXPath( - '//*[contains(@class, "components-snackbar")]/*[text()="Navigation Menu successfully created."]' - ); - - // Wait for block to resolve - let navBlock = await waitForBlock( 'Navigation' ); - - // Deselect the Nav block by inserting a new block at the root level - // outside of the Nav block. - await insertBlock( 'Paragraph' ); - - // Acquire fresh reference to block - navBlock = await waitForBlock( 'Navigation' ); - - // Check Placeholder Preview is visible. - await navBlock.waitForSelector( - '.wp-block-navigation-placeholder__preview', - { visible: true } - ); - - // Check the block's appender is not visible. - const blockAppender = await navBlock.$( - '.block-list-appender' - ); - - expect( blockAppender ).toBeNull(); - } ); - } ); - - describe( 'menu selector actions', () => { - it( 'allows a navigation block to be created from existing menus', async () => { - await createClassicMenu( { name: 'Test Menu 1' } ); - await createClassicMenu( - { name: 'Test Menu 2' }, - menuItemsFixture - ); - - await createNewPost(); - await insertBlock( 'Navigation' ); - await selectClassicMenu( 'Test Menu 2' ); - - // Wait for a navigation link block before making assertion. - await page.waitForSelector( - '*[aria-label="Block: Custom Link"]' - ); - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - } ); - - it( 'creates an empty navigation block when the selected existing menu is also empty', async () => { - await createClassicMenu( { name: 'Test Menu 1' } ); - await createNewPost(); - await insertBlock( 'Navigation' ); - await selectClassicMenu( 'Test Menu 1' ); - - await page.waitForNetworkIdle(); - - // Wait for the appender so that we know the navigation menu was created. - await page.waitForSelector( - 'nav[aria-label="Block: Navigation"] button[aria-label="Add block"]' - ); - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - } ); - - it( 'does not display the options to create from existing menus if there are no existing menus', async () => { - await createNewPost(); - - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - - await page.waitForSelector( '.components-menu-group' ); - - const placeholderActionsLength = await page.$$eval( - '.components-menu-group', - ( els ) => els.length - ); - - // Should only be showing "Create new menu". - expect( placeholderActionsLength ).toEqual( 1 ); - } ); - } ); - } ); - - it( 'allows an empty navigation block to be created and manually populated using a mixture of internal and external links', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - await showBlockToolbar(); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Add a link to the Link block. - await updateActiveNavigationLink( { - url: 'https://wordpress.org', - label: 'WP', - type: 'url', - } ); - - // Select the parent navigation block to show the appender. - await showBlockToolbar(); - await page.click( 'button[aria-label="Select Navigation"]' ); - - const appenderAgain = await page.waitForSelector( - '.wp-block-navigation .block-list-appender' - ); - await appenderAgain.click(); - - // After adding a new block, search input should be shown immediately. - // Verify that Escape would close the popover. - // Regression: https://github.com/WordPress/gutenberg/pull/19885 - // Wait for URL input to be focused. - await page.waitForSelector( - 'input.block-editor-url-input__input:focus' - ); - - // After adding a new block, search input should be shown immediately. - const isInURLInput = await page.evaluate( - () => - !! document.activeElement.matches( - 'input.block-editor-url-input__input' - ) - ); - expect( isInURLInput ).toBe( true ); - await page.keyboard.press( 'Escape' ); - - // Click the link placeholder. - const placeholder = await page.waitForSelector( - '.wp-block-navigation-link__placeholder' - ); - await placeholder.click(); - - // For the second nav link block use an existing internal page. - // Mock the api response so that it's consistent. - await mockSearchResponse( [ - { title: 'Get in Touch', slug: 'get-in-touch' }, - ] ); - - // Add a link to the default Link block. - await updateActiveNavigationLink( { - url: 'Get in Touch', - label: 'Contact', - type: 'entity', - } ); - - await publishPost(); - - // Expect a Navigation Block with two Links in the snapshot. - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - } ); - - it( 'encodes URL when create block if needed', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Add a link to the Link block. - await updateActiveNavigationLink( { - url: 'https://wordpress.org/шеллы', - type: 'url', - } ); - - await showBlockToolbar(); - await page.click( 'button[aria-label="Select Navigation"]' ); - - const appenderAgain = await page.waitForSelector( - '.wp-block-navigation .block-list-appender' - ); - await appenderAgain.click(); - - // Wait for URL input to be focused. - await page.waitForSelector( - 'input.block-editor-url-input__input:focus' - ); - - // After adding a new block, search input should be shown immediately. - const isInURLInput = await page.evaluate( - () => - !! document.activeElement.matches( - 'input.block-editor-url-input__input' - ) - ); - expect( isInURLInput ).toBe( true ); - await page.keyboard.press( 'Escape' ); - - // Click the link placeholder. - const placeholder = await page.waitForSelector( - '.wp-block-navigation-link__placeholder' - ); - await placeholder.click(); - - // Mocked response for internal page. - // We are encoding the slug/url in order - // that we can assert it is not double encoded by the block. - await mockSearchResponse( [ - { title: 'お問い合わせ', slug: encodeURI( 'お問い合わせ' ) }, - ] ); - - // Select the mocked internal page above. - await updateActiveNavigationLink( { - url: 'お問い合わせ', - type: 'entity', - } ); - - await publishPost(); - - // Expect a Navigation Block with two Links in the snapshot. - // The 2nd link should not be double encoded. - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - } ); - - it( 'allows pages to be created from the navigation block and their links added to menu', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Wait for URL input to be focused - // Insert name for the new page. - const pageTitle = 'A really long page name that will not exist'; - const input = await page.waitForSelector( - 'input.block-editor-url-input__input:focus' - ); - await input.type( pageTitle ); - - // When creating a page, the URLControl makes a request to the - // url-details endpoint to fetch information about the page. - // Because the draft is inaccessible publicly, this request - // returns a 404 response. Wait for the response and expect - // the error to have occurred. - const createPageButton = await page.waitForSelector( - '.block-editor-link-control__search-create' - ); - const responsePromise = page.waitForResponse( - ( response ) => - response.url().includes( 'url-details' ) && - response.status() === 404 - ); - const createPagePromise = createPageButton.click(); - await Promise.all( [ responsePromise, createPagePromise ] ); - - // Creating a draft is async, so wait for a sign of completion. In this - // case the link that shows in the URL popover once a link is added. - await page.waitForXPath( - `//a[contains(@class, "block-editor-link-control__search-item-title") and contains(., "${ pageTitle }")]` - ); - - await publishPost(); - - // Expect a Navigation Block with a link for "A really long page name that will not exist". - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - expect( console ).toHaveErroredWith( - 'Failed to load resource: the server responded with a status of 404 (Not Found)' - ); - } ); - - it( 'correctly decodes special characters in the created Page title for display', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Wait for URL input to be focused - // Insert name for the new page. - const pageTitle = 'This & That & Some < other > chars'; - const input = await page.waitForSelector( - 'input.block-editor-url-input__input:focus' - ); - await input.type( pageTitle ); - - // When creating a page, the URLControl makes a request to the - // url-details endpoint to fetch information about the page. - // Because the draft is inaccessible publicly, this request - // returns a 404 response. Wait for the response and expect - // the error to have occurred. - const createPageButton = await page.waitForSelector( - '.block-editor-link-control__search-create' - ); - const responsePromise = page.waitForResponse( - ( response ) => - response.url().includes( 'url-details' ) && - response.status() === 404 - ); - const createPagePromise = createPageButton.click(); - await Promise.all( [ responsePromise, createPagePromise ] ); - - await waitForBlock( 'Navigation' ); - - const innerLinkBlock = await waitForBlock( 'Custom Link' ); - - const linkText = await innerLinkBlock.$eval( - '[aria-label="Navigation link text"]', - ( element ) => { - return element.innerText; - } - ); - - expect( linkText ).toContain( pageTitle ); - - expect( console ).toHaveErroredWith( - 'Failed to load resource: the server responded with a status of 404 (Not Found)' - ); - } ); - - it( 'renders buttons for the submenu opener elements when the block is set to open on click instead of hover', async () => { - await createClassicMenu( { name: 'Test Menu 2' }, menuItemsFixture ); - await createNewPost(); - await insertBlock( 'Navigation' ); - await selectClassicMenu( 'Test Menu 2' ); - - await ensureSidebarOpened(); - const openOnClickButton = await page.waitForXPath( - '//label[contains(text(),"Open on click")]' - ); - - await openOnClickButton.click(); - - await saveDraft(); - - // Scope element selector to the Editor's "Content" region as otherwise it picks up on - // block previews. - const navSubmenuSelector = - '[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"]'; - - await page.waitForSelector( navSubmenuSelector ); - - const navSubmenusLength = await page.$$eval( - navSubmenuSelector, - ( els ) => els.length - ); - - const navButtonTogglesSelector = - '[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"] button.wp-block-navigation-item__content'; - - await page.waitForSelector( navButtonTogglesSelector ); - - const navButtonTogglesLength = await page.$$eval( - navButtonTogglesSelector, - ( els ) => els.length - ); - - // Assert the correct number of button toggles are present. - expect( navSubmenusLength ).toEqual( navButtonTogglesLength ); - } ); - - it( 'Shows the quick inserter when the block contains non-navigation specific blocks', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Add a link to the Link block. - await updateActiveNavigationLink( { - url: 'https://wordpress.org', - label: 'WP', - type: 'url', - } ); - - // Now add a different block type. - await insertBlock( 'Site Title' ); - - await showBlockToolbar(); - await page.click( 'button[aria-label="Select Navigation"]' ); - const appenderAgain = await page.waitForSelector( - '.wp-block-navigation .block-list-appender' - ); - await appenderAgain.click(); - - const quickInserter = await page.waitForSelector( - '.block-editor-inserter__quick-inserter' - ); - - // Expect the quick inserter to be truthy, which it will be because we - // waited for it. It's nice to end a test with an assertion though. - expect( quickInserter ).toBeTruthy(); - } ); - - describe( 'Creating and restarting', () => { - const NAV_ENTITY_SELECTOR = - '//div[@class="entities-saved-states__panel"]//label//strong[contains(text(), "Navigation")]'; - - it( 'respects the nesting level', async () => { - await createNewPost(); - - await insertBlock( 'Navigation' ); - - const navBlock = await waitForBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - await clickOnMoreMenuItem( 'Code editor' ); - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - - let code = await codeEditorInput.evaluate( ( el ) => el.value ); - code = code.replace( '} /-->', ',"maxNestingLevel":0} /-->' ); - await codeEditorInput.evaluate( - ( el, newCode ) => ( el.value = newCode ), - code - ); - await clickButton( 'Exit code editor' ); - - const blockAppender = navBlock.$( '.block-list-appender' ); - - expect( blockAppender ).not.toBeNull(); - - // Check the Submenu block is no longer present. - const navSubmenuSelector = - '[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"]'; - const submenuBlock = await page.$( navSubmenuSelector ); - - expect( submenuBlock ).toBeFalsy(); - } ); - - it( 'retains initial uncontrolled inner blocks whilst there are no modifications to those blocks', async () => { - await createNewPost(); - await clickOnMoreMenuItem( 'Code editor' ); - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - await codeEditorInput.click(); - - const markup = - ''; - await page.keyboard.type( markup ); - await clickButton( 'Exit code editor' ); - - const navBlock = await waitForBlock( 'Navigation' ); - - // Select the block - await navBlock.click(); - - const hasUncontrolledInnerBlocks = await page.evaluate( () => { - const blocks = wp.data - .select( 'core/block-editor' ) - .getBlocks(); - return !! blocks[ 0 ]?.innerBlocks?.length; - } ); - - expect( hasUncontrolledInnerBlocks ).toBe( true ); - } ); - - it( 'converts uncontrolled inner blocks to an entity when modifications are made to the blocks', async () => { - await rest( { - method: 'POST', - path: `/wp/v2/pages/`, - data: { - status: 'publish', - title: 'A Test Page', - content: 'Hello world', - }, - } ); - - // Insert 'old-school' inner blocks via the code editor. - await createNewPost(); - await clickOnMoreMenuItem( 'Code editor' ); - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - await codeEditorInput.click(); - const markup = - ''; - await page.keyboard.type( markup ); - - await clickButton( 'Exit code editor' ); - - const navBlock = await waitForBlock( 'Navigation' ); - - await navBlock.click(); - - // Wait for the Page List to have resolved and render as a `
    `. - await page.waitForSelector( - `[aria-label="Editor content"][role="region"] ul[aria-label="Block: Page List"]` - ); - - // Select the Page List block. - await openListView(); - - const navExpander = await page.waitForXPath( - `//a[.//span[text()='Navigation']]/span[contains(@class, 'block-editor-list-view__expander')]` - ); - - await navExpander.click(); - - const pageListBlock = ( - await getListViewBlocks( 'Page List' ) - )[ 0 ]; - - await pageListBlock.click(); - - // Modify the uncontrolled inner blocks by converting Page List. - await clickBlockToolbarButton( 'Edit' ); - - // Must wait for button to be enabled. - const convertButton = await page.waitForXPath( - `//button[not(@disabled) and text()="Convert"]` - ); - - await convertButton.click(); - - // Wait for new Nav Menu entity to be created as a result of the modification to inner blocks. - await page.waitForXPath( - `//*[contains(@class, 'components-snackbar__content')][ text()="New Navigation Menu created." ]` - ); - - await publishPost(); - - // Check that the wp_navigation post exists and has the page list block. - expect( await getNavigationMenuRawContent() ).toMatchSnapshot(); - } ); - - it( 'only updates a single entity currently linked with the block', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Confirm that the menu entity was updated. - const publishPanelButton = await page.waitForSelector( - '.editor-post-publish-panel__toggle:not([aria-disabled="true"])' - ); - await publishPanelButton.click(); - - await page.waitForXPath( NAV_ENTITY_SELECTOR ); - expect( await page.$x( NAV_ENTITY_SELECTOR ) ).toHaveLength( 1 ); - - // Publish the post. - const entitySaveButton = await page.waitForSelector( - '.editor-entities-saved-states__save-button' - ); - await entitySaveButton.click(); - const publishButton = await page.waitForSelector( - '.editor-post-publish-button:not([aria-disabled="true"])' - ); - await publishButton.click(); - - // A success notice should show up. - await page.waitForXPath( - `//*[contains(@class, 'components-snackbar__content')][ text()="Post published." ]` - ); - - // Now try inserting another Link block via the quick inserter. - // await page.click( 'nav[aria-label="Block: Navigation"]' ); - await forceSelectNavigationBlock(); - - const newNavigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await newNavigationSelector.click(); - - const newCreateNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await newCreateNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const newAppender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await newAppender.click(); - - // Confirm that only the last menu entity was updated. - const publishPanelButton2 = await page.waitForSelector( - '.editor-post-publish-button__button:not([aria-disabled="true"])' - ); - await publishPanelButton2.click(); - - await page.waitForXPath( NAV_ENTITY_SELECTOR ); - expect( await page.$x( NAV_ENTITY_SELECTOR ) ).toHaveLength( 1 ); - } ); - } ); - - it( 'applies accessible label to block element', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - // Add a link to the Link block. - await updateActiveNavigationLink( { - url: 'https://wordpress.org', - label: 'WP', - type: 'url', - } ); - - const previewPage = await openPreviewPage(); - await previewPage.bringToFront(); - await previewPage.waitForNetworkIdle(); - - const isAccessibleLabelPresent = await previewPage.$( - 'nav[aria-label="Navigation"]' - ); - - expect( isAccessibleLabelPresent ).toBeTruthy(); - } ); - - it( 'does not load the frontend script if no navigation blocks are present', async () => { - await createNewPost(); - await insertBlock( 'Paragraph' ); - await page.waitForSelector( 'p[data-title="Paragraph"]:focus' ); - await page.keyboard.type( 'Hello' ); - - const previewPage = await openPreviewPage(); - await previewPage.bringToFront(); - await previewPage.waitForNetworkIdle(); - - const isScriptLoaded = await previewPage.evaluate( - () => - null !== - document.querySelector( - 'script[src*="navigation/view.min.js"]' - ) - ); - - expect( isScriptLoaded ).toBe( false ); - } ); - - it( 'loads the frontend script only once even when multiple navigation blocks are present', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - await insertBlock( 'Navigation' ); - - const newNavigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await newNavigationSelector.click(); - - const newCreateNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await newCreateNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const previewPage = await openPreviewPage(); - await previewPage.bringToFront(); - await previewPage.waitForNetworkIdle(); - - const tagCount = await previewPage.evaluate( - () => - document.querySelectorAll( - 'script[src*="navigation/view.min.js"]' - ).length - ); - - expect( tagCount ).toBe( 1 ); - } ); - - describe( 'Submenus', () => { - it( 'shows button which converts submenu to link when submenu is not-populated (empty)', async () => { - const navSubmenuSelector = `[aria-label="Editor content"][role="region"] [aria-label="Block: Submenu"]`; - - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - await clickBlockToolbarButton( 'Add submenu' ); - - await waitForBlock( 'Submenu' ); - - // Revert the Submenu back to a Navigation Link block. - await clickBlockToolbarButton( 'Convert to Link' ); - - // Check the Submenu block is no longer present. - const submenuBlock = await page.$( navSubmenuSelector ); - - expect( submenuBlock ).toBeFalsy(); - } ); - - it( 'shows button to convert submenu to link in disabled state when submenu is populated', async () => { - await createNewPost(); - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - await updateActiveNavigationLink( { - url: 'https://make.wordpress.org/core/', - label: 'Menu item #1', - type: 'url', - } ); - - await clickBlockToolbarButton( 'Add submenu' ); - - await waitForBlock( 'Submenu' ); - - // Add a Link block first. - const SubAppender = await page.waitForSelector( - '[aria-label="Block: Submenu"] [aria-label="Add block"]' - ); - - await SubAppender.click(); - - await updateActiveNavigationLink( { - url: 'https://make.wordpress.org/core/', - label: 'Submenu item #1', - type: 'url', - } ); - - await clickBlockToolbarButton( 'Select Submenu' ); - - // Check button exists but is in disabled state. - const disabledConvertToLinkButton = await page.$$eval( - '[aria-label="Block tools"] [aria-label="Convert to Link"][disabled]', - ( els ) => els.length - ); - - expect( disabledConvertToLinkButton ).toEqual( 1 ); - } ); - - it( 'shows button to convert submenu to link when submenu is populated with a single incomplete link item', async () => { - // For context on why this test is required please see: - // https://github.com/WordPress/gutenberg/pull/38203#issuecomment-1027672948. - - await createNewPost(); - await insertBlock( 'Navigation' ); - - await clickBlockToolbarButton( 'Select Menu' ); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - - const appender = await page.waitForSelector( - '.wp-block-navigation .block-editor-button-block-appender' - ); - await appender.click(); - - await updateActiveNavigationLink( { - url: 'https://make.wordpress.org/core/', - label: 'Menu item #1', - type: 'url', - } ); - - await clickBlockToolbarButton( 'Add submenu' ); - - await waitForBlock( 'Submenu' ); - - // Add a Link block first. - const subAppender = await page.waitForSelector( - '[aria-label="Block: Submenu"] [aria-label="Add block"]' - ); - - await subAppender.click(); - - // Here we intentionally do not populate the inserted Navigation Link block. - // Rather we immediaely click away leaving the link in a state where it has - // no URL of label and can be considered unpopulated. - await clickBlockToolbarButton( 'Select Submenu' ); - - // Check for non-disabled Convert to Link button. - const convertToLinkButton = await page.$( - '[aria-label="Block tools"] [aria-label="Convert to Link"]:not([disabled])' - ); - - expect( convertToLinkButton ).toBeTruthy(); - } ); - } ); - - describe( 'Permission based restrictions', () => { - afterEach( async () => { - await switchUserToAdmin(); - } ); - - it.skip( 'shows a warning if user does not have permission to create navigation menus', async () => { - const noticeText = - 'You do not have permission to create Navigation Menus.'; - - // Switch to a Contributor role user - they should not have - // permission to update Navigations. - await loginUser( contributorUsername, contributorPassword ); - - await createNewPost(); - await insertBlock( 'Navigation' ); - - // Make sure the snackbar error shows up. - await page.waitForXPath( - `//*[contains(@class, 'components-snackbar__content')][ text()="${ noticeText }" ]` - ); - - // Expect a console 403 for requests to: - // * /wp/v2/settings?_locale=user - // * /wp/v2/templates?context=edit&post_type=post&per_page=100&_locale=user - expect( console ).toHaveErrored(); - } ); - } ); - - describe( 'Initial block insertion state', () => { - async function createNavigationMenu( menu = {} ) { - return rest( { - method: 'POST', - path: '/wp/v2/navigation', - data: { - status: 'publish', - ...menu, - }, - } ); - } - - afterEach( async () => { - const navMenusEndpoint = '/wp/v2/navigation'; - const allNavMenus = await rest( { path: navMenusEndpoint } ); - - if ( ! allNavMenus?.length ) { - return; - } - - return batch( - allNavMenus.map( ( menu ) => ( { - method: 'DELETE', - path: `${ navMenusEndpoint }/${ menu.id }?force=true`, - } ) ) - ); - } ); - - it( 'automatically uses the first Navigation Menu if only one is available', async () => { - await createNavigationMenu( { - title: 'Example Navigation', - content: - '', - } ); - - await createNewPost(); - - await insertBlock( 'Navigation' ); - - await waitForBlock( 'Navigation' ); - - const innerLinkBlock = await waitForBlock( 'Custom Link' ); - - const linkText = await innerLinkBlock.$eval( - '[aria-label="Navigation link text"]', - ( element ) => { - return element.innerText; - } - ); - - expect( linkText ).toBe( 'WordPress' ); - } ); - - it( 'does not automatically use the first Navigation Menu if uncontrolled inner blocks are present', async () => { - const pageTitle = 'A Test Page'; - - await createNavigationMenu( { - title: 'Example Navigation', - content: - '', - } ); - - await rest( { - method: 'POST', - path: `/wp/v2/pages/`, - data: { - status: 'publish', - title: pageTitle, - content: 'Hello world', - }, - } ); - - await createNewPost(); - - await clickOnMoreMenuItem( 'Code editor' ); - - const codeEditorInput = await page.waitForSelector( - '.editor-post-text-editor' - ); - await codeEditorInput.click(); - - const markup = - ''; - await page.keyboard.type( markup ); - await clickButton( 'Exit code editor' ); - - await waitForBlock( 'Navigation' ); - - const hasUncontrolledInnerBlocks = await page.evaluate( () => { - const blocks = wp.data - .select( 'core/block-editor' ) - .getBlocks(); - return !! blocks[ 0 ]?.innerBlocks?.length; - } ); - - expect( hasUncontrolledInnerBlocks ).toBe( true ); - } ); - - it( 'automatically uses most recent Navigation Menu if more than one exists', async () => { - await createNavigationMenu( { - title: 'Example Navigation', - content: - '', - } ); - - await createNavigationMenu( { - title: 'Second Example Navigation', - content: - '', - } ); - - await createNewPost(); - - await insertBlock( 'Navigation' ); - - await waitForBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - await page.waitForXPath( - '//button[@aria-checked="true"][contains(., "Second Example Navigation")]' - ); - } ); - - it( 'allows users to manually create new empty menu when block has automatically selected the first available Navigation Menu', async () => { - await createNavigationMenu( { - title: 'Example Navigation', - content: - '', - } ); - - await createNewPost(); - - await insertBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const createNewMenuButton = await page.waitForXPath( - '//button[contains(., "Create new menu")]' - ); - await createNewMenuButton.click(); - - await page.waitForNetworkIdle(); - - // Await "success" notice. - await page.waitForXPath( - '//div[@class="components-snackbar__content"][contains(text(), "Navigation Menu successfully created.")]' - ); - } ); - - it( 'should always focus select menu button after item selection', async () => { - // Create some navigation menus to work with. - await createNavigationMenu( { - title: 'First navigation', - content: - '', - } ); - await createNavigationMenu( { - title: 'Second Navigation', - content: - '', - } ); - - // Create new post. - await createNewPost(); - - // Insert new block and wait for the insert to complete. - await insertBlock( 'Navigation' ); - await waitForBlock( 'Navigation' ); - - const navigationSelector = await page.waitForXPath( - "//button[text()='Select Menu']" - ); - await navigationSelector.click(); - - const theOption = await page.waitForXPath( - "//button[@aria-checked='false'][contains(., 'First navigation')]" - ); - await theOption.click(); - - // Once the options are closed, does select menu button receive focus? - const selectMenuDropdown2 = await page.$( - '[aria-label="Select Menu"]' - ); - - await expect( selectMenuDropdown2 ).toHaveFocus(); - } ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap deleted file mode 100644 index 8b0dcaec4067f..0000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/block-deletion.test.js.snap +++ /dev/null @@ -1,111 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 1`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    - - - -

    -" -`; - -exports[`block deletion - deleting the third and fourth blocks using backspace with multi-block selection results in two remaining blocks and positions the caret at the end of the second block 2`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    - - - -
      -
    • caret was here
    • -
    -" -`; - -exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 1`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    -" -`; - -exports[`block deletion - deleting the third block using backspace in an empty block results in two remaining blocks and positions the caret at the end of the second block 2`] = ` -" -

    First paragraph

    - - - -

    Second paragraph - caret was here

    -" -`; - -exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 1`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    -" -`; - -exports[`block deletion - deleting the third block using backspace with block wrapper selection results in three remaining blocks and positions the caret at the end of the third block 2`] = ` -" -

    First paragraph

    - - - -

    Second paragraph - caret was here

    -" -`; - -exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 1`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    -" -`; - -exports[`block deletion - deleting the third block using the Remove Block menu item results in two remaining blocks and positions the caret at the end of the second block 2`] = ` -" -

    First paragraph

    - - - -

    Second paragraph - caret was here

    -" -`; - -exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 1`] = ` -" -

    First paragraph

    - - - -

    Second paragraph

    -" -`; - -exports[`block deletion - deleting the third block using the Remove Block shortcut results in two remaining blocks and positions the caret at the end of the second block 2`] = ` -" -

    First paragraph

    - - - -

    Second paragraph - caret was here

    -" -`; diff --git a/packages/e2e-tests/specs/editor/various/block-deletion.test.js b/packages/e2e-tests/specs/editor/various/block-deletion.test.js deleted file mode 100644 index e4497d45fbfaf..0000000000000 --- a/packages/e2e-tests/specs/editor/various/block-deletion.test.js +++ /dev/null @@ -1,209 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - clickBlockToolbarButton, - getEditedPostContent, - createNewPost, - isInDefaultBlock, - pressKeyWithModifier, - pressKeyTimes, - insertBlock, -} from '@wordpress/e2e-test-utils'; - -const addThreeParagraphsToNewPost = async () => { - await createNewPost(); - - // Add demo content. - await clickBlockAppender(); - await page.keyboard.type( 'First paragraph' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'Second paragraph' ); - await page.keyboard.press( 'Enter' ); -}; - -/** - * Due to an issue with the Popover component not being scrollable - * under certain conditions, Pupeteer cannot "see" the "Remove Block" - * button. This is a workaround until that issue is resolved. - * - * see: https://github.com/WordPress/gutenberg/pull/14908#discussion_r284725956 - */ -const clickOnBlockSettingsMenuRemoveBlockButton = async () => { - await clickBlockToolbarButton( 'Options' ); - - let isRemoveButton = false; - - let numButtons = await page.$$eval( - '.block-editor-block-settings-menu__content button', - ( btns ) => btns.length - ); - - // Limit by the number of buttons available. - while ( --numButtons ) { - await page.keyboard.press( 'Tab' ); - - isRemoveButton = await page.evaluate( () => { - return document.activeElement.innerText.includes( - 'Remove Paragraph' - ); - } ); - - // Stop looping once we find the button. - if ( isRemoveButton ) { - await pressKeyTimes( 'Enter', 1 ); - break; - } - } - - // Makes failures more explicit. - await expect( isRemoveButton ).toBe( true ); -}; - -describe( 'block deletion -', () => { - beforeEach( addThreeParagraphsToNewPost ); - - describe( 'deleting the third block using the Remove Block menu item', () => { - it( 'results in two remaining blocks and positions the caret at the end of the second block', async () => { - // The blocks can't be empty to trigger the toolbar. - await page.keyboard.type( 'Paragraph to remove' ); - await clickOnBlockSettingsMenuRemoveBlockButton(); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Type additional text and assert that caret position is correct by comparing to snapshot. - await page.keyboard.type( ' - caret was here' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - } ); - - describe( 'deleting the third block using the Remove Block shortcut', () => { - it( 'results in two remaining blocks and positions the caret at the end of the second block', async () => { - // Type some text to assert that the shortcut also deletes block content. - await page.keyboard.type( 'this is block 2' ); - await pressKeyWithModifier( 'access', 'z' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Type additional text and assert that caret position is correct by comparing to snapshot. - await page.keyboard.type( ' - caret was here' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - } ); - - describe( 'deleting the third block using backspace in an empty block', () => { - it( 'results in two remaining blocks and positions the caret at the end of the second block', async () => { - await page.keyboard.press( 'Backspace' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Type additional text and assert that caret position is correct by comparing to snapshot. - await page.keyboard.type( ' - caret was here' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - } ); - - describe( 'deleting the third block using backspace with block wrapper selection', () => { - it( 'results in three remaining blocks and positions the caret at the end of the third block', async () => { - // Add an image block since it's easier to click the wrapper on non-textual blocks. - await page.keyboard.type( '/image' ); - await page.waitForXPath( - `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Image')]` - ); - await page.keyboard.press( 'Enter' ); - - // Click on something that's not a block. - await page.click( '.editor-post-title' ); - - // Click on the image block so that its wrapper is selected and backspace to delete it. - await page.click( - '.wp-block[data-type="core/image"] .components-placeholder__label' - ); - await page.keyboard.press( 'Backspace' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Type additional text and assert that caret position is correct by comparing to snapshot. - await page.keyboard.type( ' - caret was here' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - } ); - - describe( 'deleting the third and fourth blocks using backspace with multi-block selection', () => { - it( 'results in two remaining blocks and positions the caret at the end of the second block', async () => { - // Add a third paragraph for this test. - await page.keyboard.type( 'Third paragraph' ); - await page.keyboard.press( 'Enter' ); - - // Press the up arrow once to select the third and fourth blocks. - await pressKeyWithModifier( 'shift', 'ArrowUp' ); - - // Now that the block wrapper is selected, press backspace to delete it. - await page.keyboard.press( 'Backspace' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Type additional text and assert that caret position is correct by comparing to snapshot. - await page.keyboard.type( ' - caret was here' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - } ); -} ); - -describe( 'deleting all blocks', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'results in the default block getting selected', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'Paragraph' ); - await clickOnBlockSettingsMenuRemoveBlockButton(); - - // There is a default block and post title: - expect( - await page.$$( '.block-editor-block-list__block' ) - ).toHaveLength( 2 ); - - // But the effective saved content is still empty: - expect( await getEditedPostContent() ).toBe( '' ); - - // And focus is retained: - expect( await isInDefaultBlock() ).toBe( true ); - } ); - - it( 'gracefully removes blocks when the default block is not available', async () => { - // Regression Test: Previously, removing a block would not clear - // selection state when there were no other blocks to select. - // - // See: https://github.com/WordPress/gutenberg/issues/15458 - // See: https://github.com/WordPress/gutenberg/pull/15543 - - // Unregister default block type. This may happen if the editor is - // configured to not allow the default (paragraph) block type, either - // by plugin editor settings filtering or user block preferences. - await page.evaluate( () => { - const defaultBlockName = wp.data - .select( 'core/blocks' ) - .getDefaultBlockName(); - wp.data - .dispatch( 'core/blocks' ) - .removeBlockTypes( defaultBlockName ); - } ); - - // Add and remove a block. - await insertBlock( 'Image' ); - await page.waitForSelector( 'figure[data-type="core/image"]' ); - await page.keyboard.press( 'ArrowUp' ); - await page.keyboard.press( 'Backspace' ); - - // Verify there is no selected block. - // TODO: There should be expectations around where focus is placed in - // this scenario. Currently, a focus loss occurs (not acceptable). - const selectedBlocksCount = await page.evaluate( () => { - return wp.data - .select( 'core/block-editor' ) - .getSelectedBlockClientIds().length; - } ); - - expect( selectedBlocksCount ).toBe( 0 ); - } ); -} ); diff --git a/packages/edit-site/src/components/global-styles/border-panel.js b/packages/edit-site/src/components/global-styles/border-panel.js index a32ad02429595..07e6254e4b34e 100644 --- a/packages/edit-site/src/components/global-styles/border-panel.js +++ b/packages/edit-site/src/components/global-styles/border-panel.js @@ -170,7 +170,7 @@ export default function BorderPanel( { name, variation = '' } ) { // global styles are saved. setBorder( { radius: border?.radius, ...updatedBorder } ); }, - [ setBorder ] + [ setBorder, border?.radius ] ); return ( diff --git a/packages/edit-site/src/components/global-styles/preview.js b/packages/edit-site/src/components/global-styles/preview.js index ec8740c345333..fa27af246448f 100644 --- a/packages/edit-site/src/components/global-styles/preview.js +++ b/packages/edit-site/src/components/global-styles/preview.js @@ -107,163 +107,178 @@ const StylesPreview = ( { label, isFocused, withHoverView } ) => { return styles; }, [ styles ] ); + const isReady = !! width; return ( - + { paletteColors + .slice( 0, 4 ) + .map( ( { color }, index ) => ( +
    + ) ) } + + + + + { label && ( +
    + { label } +
    + ) } +
    +
    + + + ) } + ); }; diff --git a/packages/edit-site/src/components/list/table.js b/packages/edit-site/src/components/list/table.js index d6f2405d76b42..8f9c039cb0ce1 100644 --- a/packages/edit-site/src/components/list/table.js +++ b/packages/edit-site/src/components/list/table.js @@ -46,6 +46,9 @@ export default function Table( { templateType } ) { ); } + const sortedTemplates = [ ...templates ]; + sortedTemplates.sort( ( a, b ) => a.slug.localeCompare( b.slug ) ); + return ( // These explicit aria roles are needed for Safari. // See https://developer.mozilla.org/en-US/docs/Web/CSS/display#tables @@ -74,7 +77,7 @@ export default function Table( { templateType } ) { - { templates.map( ( template ) => ( + { sortedTemplates.map( ( template ) => ( setCanvasMode( 'edit' ) } > { __( 'Edit' ) } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js index 5601b56318ed7..2282c03a78151 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js @@ -29,6 +29,7 @@ export default function SidebarNavigationScreenTemplate() { actions={