diff --git a/lib/blocks.php b/lib/blocks.php
index d1428770e3540e..a7442d71e84098 100644
--- a/lib/blocks.php
+++ b/lib/blocks.php
@@ -63,6 +63,7 @@ function gutenberg_reregister_core_block_types() {
'shortcode.php' => 'core/shortcode',
'social-link.php' => 'core/social-link',
'tag-cloud.php' => 'core/tag-cloud',
+ 'page-list.php' => 'core/page-list',
'post-author.php' => 'core/post-author',
'post-comment.php' => 'core/post-comment',
'post-comment-author.php' => 'core/post-comment-author',
diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss
index 45449c927bac0d..2113c72dade398 100644
--- a/packages/block-library/src/editor.scss
+++ b/packages/block-library/src/editor.scss
@@ -27,6 +27,7 @@
@import "./navigation/editor.scss";
@import "./navigation-link/editor.scss";
@import "./nextpage/editor.scss";
+@import "./page-list/editor.scss";
@import "./paragraph/editor.scss";
@import "./post-content/editor.scss";
@import "./post-excerpt/editor.scss";
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 376757f2bfb876..d470f555e20ba4 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -42,6 +42,7 @@ import * as list from './list';
import * as missing from './missing';
import * as more from './more';
import * as nextpage from './nextpage';
+import * as pageList from './page-list';
import * as preformatted from './preformatted';
import * as pullquote from './pullquote';
import * as reusableBlock from './block';
@@ -148,6 +149,7 @@ export const __experimentalGetCoreBlocks = () => [
missing,
more,
nextpage,
+ pageList,
preformatted,
pullquote,
rss,
diff --git a/packages/block-library/src/navigation/edit.js b/packages/block-library/src/navigation/edit.js
index c22be04525c8cd..9c801008d5bf3e 100644
--- a/packages/block-library/src/navigation/edit.js
+++ b/packages/block-library/src/navigation/edit.js
@@ -79,6 +79,7 @@ function Navigation( {
'core/navigation-link',
'core/search',
'core/social-links',
+ 'core/page-list',
],
orientation: attributes.orientation || 'horizontal',
renderAppender:
diff --git a/packages/block-library/src/navigation/placeholder.js b/packages/block-library/src/navigation/placeholder.js
index 28dc39fe0142c0..9f6c58267caab3 100644
--- a/packages/block-library/src/navigation/placeholder.js
+++ b/packages/block-library/src/navigation/placeholder.js
@@ -97,29 +97,6 @@ function convertMenuItemsToBlocks( menuItems ) {
return mapMenuItemsToBlocks( menuTree );
}
-/**
- * Convert pages to blocks.
- *
- * @param {Object[]} pages An array of pages.
- *
- * @return {WPBlock[]} An array of blocks.
- */
-function convertPagesToBlocks( pages ) {
- if ( ! pages ) {
- return null;
- }
-
- return pages.map( ( { title, type, link: url, id } ) =>
- createBlock( 'core/navigation-link', {
- type,
- id,
- url,
- label: ! title.rendered ? __( '(no title)' ) : title.rendered,
- opensInNewTab: false,
- } )
- );
-}
-
function NavigationPlaceholder( { onCreate }, ref ) {
const [ selectedMenu, setSelectedMenu ] = useState();
@@ -220,9 +197,9 @@ function NavigationPlaceholder( { onCreate }, ref ) {
};
const onCreateAllPages = () => {
- const blocks = convertPagesToBlocks( pages );
+ const block = [ createBlock( 'core/page-list' ) ];
const selectNavigationBlock = true;
- onCreate( blocks, selectNavigationBlock );
+ onCreate( block, selectNavigationBlock );
};
useEffect( () => {
diff --git a/packages/block-library/src/page-list/block.json b/packages/block-library/src/page-list/block.json
new file mode 100644
index 00000000000000..1f7a1fe9993502
--- /dev/null
+++ b/packages/block-library/src/page-list/block.json
@@ -0,0 +1,20 @@
+{
+ "apiVersion": 2,
+ "name": "core/page-list",
+ "category": "widgets",
+ "usesContext": [
+ "textColor",
+ "customTextColor",
+ "backgroundColor",
+ "customBackgroundColor",
+ "fontSize",
+ "customFontSize",
+ "showSubmenuIcon"
+ ],
+ "supports": {
+ "reusable": false,
+ "html": false
+ },
+ "editorStyle": "wp-block-page-list-editor",
+ "style": "wp-block-page-list"
+}
diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js
new file mode 100644
index 00000000000000..8d9b42603b3545
--- /dev/null
+++ b/packages/block-library/src/page-list/edit.js
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+
+import { useBlockProps } from '@wordpress/block-editor';
+import ServerSideRender from '@wordpress/server-side-render';
+
+export default function PageListEdit( { context } ) {
+ const { textColor, backgroundColor, showSubmenuIcon } = context || {};
+
+ const blockProps = useBlockProps( {
+ className: classnames( {
+ 'has-text-color': !! textColor,
+ [ `has-${ textColor }-color` ]: !! textColor,
+ 'has-background': !! backgroundColor,
+ [ `has-${ backgroundColor }-background-color` ]: !! backgroundColor,
+ 'show-submenu-icons': !! showSubmenuIcon,
+ } ),
+ } );
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/block-library/src/page-list/editor.scss b/packages/block-library/src/page-list/editor.scss
new file mode 100644
index 00000000000000..1cda12d87ac8ca
--- /dev/null
+++ b/packages/block-library/src/page-list/editor.scss
@@ -0,0 +1,29 @@
+.wp-block-navigation {
+ // Block wrapper gets the classes in the editor, and there's an extra div wrapper for now, so background styles need to be inherited.
+ .wp-block-page-list > div,
+ .wp-block-page-list {
+ background-color: inherit;
+ }
+ // Make the dropdown background white if there's no background color set.
+ &:not(.has-background) {
+ .submenu-container {
+ color: $gray-900;
+ background-color: $white;
+ }
+ }
+}
+
+// Make links unclickable in the editor
+.wp-block-pages-list__item__link {
+ pointer-events: none;
+}
+
+.wp-block-page-list .components-placeholder {
+ min-height: 0;
+ padding: 0;
+ background-color: inherit;
+
+ .components-spinner {
+ margin-top: 0;
+ }
+}
diff --git a/packages/block-library/src/page-list/index.js b/packages/block-library/src/page-list/index.js
new file mode 100644
index 00000000000000..f75a945716b501
--- /dev/null
+++ b/packages/block-library/src/page-list/index.js
@@ -0,0 +1,24 @@
+/**
+ * WordPress dependencies
+ */
+import { pages as icon } from '@wordpress/icons';
+import { __, _x } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import edit from './edit.js';
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title: _x( 'Page List', 'block title' ),
+ description: __( 'Display a list of all pages.' ),
+ keywords: [ __( 'menu' ), __( 'navigation' ) ],
+ icon,
+ example: {},
+ edit,
+};
diff --git a/packages/block-library/src/page-list/index.php b/packages/block-library/src/page-list/index.php
new file mode 100644
index 00000000000000..bd607ad4879d9b
--- /dev/null
+++ b/packages/block-library/src/page-list/index.php
@@ -0,0 +1,217 @@
+ array(),
+ 'inline_styles' => '',
+ );
+
+ // Text color.
+ $has_named_text_color = array_key_exists( 'textColor', $context );
+ $has_custom_text_color = array_key_exists( 'customTextColor', $context );
+
+ // If has text color.
+ if ( $has_custom_text_color || $has_named_text_color ) {
+ // Add has-text-color class.
+ $colors['css_classes'][] = 'has-text-color';
+ }
+
+ if ( $has_named_text_color ) {
+ // Add the color class.
+ $colors['css_classes'][] = sprintf( 'has-%s-color', $context['textColor'] );
+ } elseif ( $has_custom_text_color ) {
+ // Add the custom color inline style.
+ $colors['inline_styles'] .= sprintf( 'color: %s;', $context['customTextColor'] );
+ }
+
+ // Background color.
+ $has_named_background_color = array_key_exists( 'backgroundColor', $context );
+ $has_custom_background_color = array_key_exists( 'customBackgroundColor', $context );
+
+ // If has background color.
+ if ( $has_custom_background_color || $has_named_background_color ) {
+ // Add has-background class.
+ $colors['css_classes'][] = 'has-background';
+ }
+
+ if ( $has_named_background_color ) {
+ // Add the background-color class.
+ $colors['css_classes'][] = sprintf( 'has-%s-background-color', $context['backgroundColor'] );
+ } elseif ( $has_custom_background_color ) {
+ // Add the custom background-color inline style.
+ $colors['inline_styles'] .= sprintf( 'background-color: %s;', $context['customBackgroundColor'] );
+ }
+
+ return $colors;
+}
+
+/**
+ * Build an array with CSS classes and inline styles defining the font sizes
+ * which will be applied to the pages markup in the front-end when it is a descendant of navigation.
+ *
+ * @param array $context Navigation block context.
+ * @return array Font size CSS classes and inline styles.
+ */
+function block_core_page_list_build_css_font_sizes( $context ) {
+ // CSS classes.
+ $font_sizes = array(
+ 'css_classes' => array(),
+ 'inline_styles' => '',
+ );
+
+ $has_named_font_size = array_key_exists( 'fontSize', $context );
+ $has_custom_font_size = array_key_exists( 'customFontSize', $context );
+
+ if ( $has_named_font_size ) {
+ // Add the font size class.
+ $font_sizes['css_classes'][] = sprintf( 'has-%s-font-size', $context['fontSize'] );
+ } elseif ( $has_custom_font_size ) {
+ // Add the custom font size inline style.
+ $font_sizes['inline_styles'] = sprintf( 'font-size: %spx;', $context['customFontSize'] );
+ }
+
+ return $font_sizes;
+}
+
+/**
+ * Outputs Page list markup from an array of pages with nested children.
+ *
+ * @param array $nested_pages The array of nested pages.
+ *
+ * @return string List markup.
+ */
+function render_nested_page_list( $nested_pages ) {
+ if ( empty( $nested_pages ) ) {
+ return;
+ }
+ $markup = '';
+ foreach ( (array) $nested_pages as $page ) {
+ $css_class = 'wp-block-pages-list__item';
+ if ( isset( $page['children'] ) ) {
+ $css_class .= ' has-child';
+ }
+ $markup .= '';
+ $markup .= '' . wp_kses(
+ $page['title'],
+ wp_kses_allowed_html( 'post' )
+ ) . '';
+ if ( isset( $page['children'] ) ) {
+ $markup .= '';
+ $markup .= '';
+ }
+ $markup .= '';
+ }
+ return $markup;
+}
+
+/**
+ * Outputs nested array of pages
+ *
+ * @param array $current_level The level being iterated through.
+ * @param array $children The children grouped by parent post ID.
+ *
+ * @return array The nested array of pages.
+ */
+function nest_pages( $current_level, $children ) {
+ if ( empty( $current_level ) ) {
+ return;
+ }
+ foreach ( (array) $current_level as $key => $current ) {
+ if ( isset( $children[ $key ] ) ) {
+ $current_level[ $key ]['children'] = nest_pages( $children[ $key ], $children );
+ }
+ }
+ return $current_level;
+}
+
+/**
+ * Renders the `core/page-list` block on server.
+ *
+ * @param array $attributes The block attributes.
+ * @param array $content The saved content.
+ * @param array $block The parsed block.
+ *
+ * @return string Returns the page list markup.
+ */
+function render_block_core_page_list( $attributes, $content, $block ) {
+ static $block_id = 0;
+ $block_id++;
+
+ $all_pages = get_pages( array( 'sort_column' => 'menu_order' ) );
+
+ $top_level_pages = array();
+
+ $pages_with_children = array();
+
+ foreach ( (array) $all_pages as $page ) {
+ if ( $page->post_parent ) {
+ $pages_with_children[ $page->post_parent ][ $page->ID ] = array(
+ 'title' => $page->post_title,
+ 'link' => get_permalink( $page->ID ),
+ );
+ } else {
+ $top_level_pages[ $page->ID ] = array(
+ 'title' => $page->post_title,
+ 'link' => get_permalink( $page->ID ),
+ );
+
+ }
+ }
+
+ $nested_pages = nest_pages( $top_level_pages, $pages_with_children );
+
+ $wrapper_markup = '';
+
+ $items_markup = render_nested_page_list( $nested_pages );
+
+ $colors = block_core_page_list_build_css_colors( $block->context );
+ $font_sizes = block_core_page_list_build_css_font_sizes( $block->context );
+ $classes = array_merge(
+ $colors['css_classes'],
+ $font_sizes['css_classes']
+ );
+ $style_attribute = ( $colors['inline_styles'] || $font_sizes['inline_styles'] );
+ $css_classes = trim( implode( ' ', $classes ) );
+
+ if ( $block->context && $block->context['showSubmenuIcon'] ) {
+ $css_classes .= ' show-submenu-icons';
+ }
+
+ $wrapper_attributes = get_block_wrapper_attributes(
+ array(
+ 'class' => $css_classes,
+ 'style' => $style_attribute,
+ )
+ );
+
+ return sprintf(
+ $wrapper_markup,
+ $wrapper_attributes,
+ $items_markup
+ );
+}
+
+ /**
+ * Registers the `core/pages` block on server.
+ */
+function register_block_core_page_list() {
+ register_block_type_from_metadata(
+ __DIR__ . '/page-list',
+ array(
+ 'render_callback' => 'render_block_core_page_list',
+ )
+ );
+}
+ add_action( 'init', 'register_block_core_page_list' );
diff --git a/packages/block-library/src/page-list/style.scss b/packages/block-library/src/page-list/style.scss
new file mode 100644
index 00000000000000..9c65b730255b2d
--- /dev/null
+++ b/packages/block-library/src/page-list/style.scss
@@ -0,0 +1,103 @@
+.wp-block-page-list__submenu-icon {
+ display: none;
+}
+
+.show-submenu-icons {
+ .wp-block-page-list__submenu-icon {
+ display: block;
+ padding: 0.375em 1em 0.375em 0;
+
+ svg {
+ fill: currentColor;
+ }
+ }
+}
+
+// The Pages block should inherit navigation styles when nested within it
+.wp-block-navigation {
+ .wp-block-page-list {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .wp-block-pages-list__item__link {
+ display: block;
+ color: inherit;
+ padding: 0.5em 1em;
+ }
+
+ .wp-block-pages-list__item.has-child {
+ display: flex;
+ position: relative;
+ background-color: inherit;
+
+ > a {
+ padding-right: 0.5em;
+ }
+ > .submenu-container {
+ border: $border-width solid rgba(0, 0, 0, 0.15);
+ background-color: inherit;
+ color: inherit;
+ position: absolute;
+ left: 0;
+ top: 100%;
+ width: fit-content;
+ z-index: 2;
+ opacity: 0;
+ transition: opacity 0.1s linear;
+ visibility: hidden;
+
+ @include break-medium {
+ left: 1.5em;
+
+ // Nested submenus sit to the left on large breakpoints
+ .submenu-container {
+ left: 100%;
+ top: -1px;
+
+ // Prevent the menu from disappearing when the mouse is over the gap
+ &::before {
+ content: "";
+ position: absolute;
+ right: 100%;
+ height: 100%;
+ display: block;
+ width: 0.5em;
+ background: transparent;
+ }
+ }
+
+ .wp-block-navigation-link__submenu-icon svg {
+ transform: rotate(0);
+ }
+ }
+ }
+
+ &:hover {
+ cursor: pointer;
+
+ > .submenu-container {
+ visibility: visible;
+ opacity: 1;
+ }
+ }
+
+ &:focus-within {
+ cursor: pointer;
+
+ > .submenu-container {
+ visibility: visible;
+ opacity: 1;
+ }
+ }
+ }
+
+ .submenu-container {
+ padding: 0;
+ }
+}
+
+.is-vertical .wp-block-navigation__container {
+ .wp-block-page-list {
+ display: block;
+ }
+}
diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss
index a8d23e1563793d..f4c68ba875bb4f 100644
--- a/packages/block-library/src/style.scss
+++ b/packages/block-library/src/style.scss
@@ -23,6 +23,7 @@
@import "./media-text/style.scss";
@import "./navigation/style.scss";
@import "./navigation-link/style.scss";
+@import "./page-list/style.scss";
@import "./paragraph/style.scss";
@import "./post-author/style.scss";
@import "./post-comments-form/style.scss";
diff --git a/packages/e2e-tests/fixtures/blocks/core__page-list.html b/packages/e2e-tests/fixtures/blocks/core__page-list.html
new file mode 100644
index 00000000000000..c17450f4f34a99
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__page-list.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/e2e-tests/fixtures/blocks/core__page-list.json b/packages/e2e-tests/fixtures/blocks/core__page-list.json
new file mode 100644
index 00000000000000..df189be152f5bf
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__page-list.json
@@ -0,0 +1,10 @@
+[
+ {
+ "clientId": "_clientId_0",
+ "name": "core/page-list",
+ "isValid": true,
+ "attributes": {},
+ "innerBlocks": [],
+ "originalContent": ""
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__page-list.parsed.json b/packages/e2e-tests/fixtures/blocks/core__page-list.parsed.json
new file mode 100644
index 00000000000000..3cc84a0f5c4edd
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__page-list.parsed.json
@@ -0,0 +1,9 @@
+[
+ {
+ "blockName": "core/page-list",
+ "attrs": {},
+ "innerBlocks": [],
+ "innerHTML": "",
+ "innerContent": []
+ }
+]
diff --git a/packages/e2e-tests/fixtures/blocks/core__page-list.serialized.html b/packages/e2e-tests/fixtures/blocks/core__page-list.serialized.html
new file mode 100644
index 00000000000000..8f4e614a2eaa09
--- /dev/null
+++ b/packages/e2e-tests/fixtures/blocks/core__page-list.serialized.html
@@ -0,0 +1 @@
+
diff --git a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
index a611ece15ba24f..67b195b021c0e1 100644
--- a/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
+++ b/packages/e2e-tests/specs/experiments/__snapshots__/navigation-editor.test.js.snap
@@ -2,11 +2,7 @@
exports[`Navigation editor allows creation of a menu 1`] = `
"
-
-
-
-
-
+
"
`;
diff --git a/packages/e2e-tests/specs/experiments/navigation-editor.test.js b/packages/e2e-tests/specs/experiments/navigation-editor.test.js
index 44a6d5a8029867..757f7e753523b4 100644
--- a/packages/e2e-tests/specs/experiments/navigation-editor.test.js
+++ b/packages/e2e-tests/specs/experiments/navigation-editor.test.js
@@ -29,21 +29,6 @@ const menusFixture = [
},
];
-const pagesFixture = [
- {
- title: 'Home',
- slug: 'home',
- },
- {
- title: 'About',
- slug: 'about',
- },
- {
- title: 'Contact',
- slug: 'contact',
- },
-];
-
// Matching against variations of the same URL encoded and non-encoded
// produces the most reliable mocking.
const REST_MENUS_ROUTES = [
@@ -54,10 +39,6 @@ const REST_MENU_ITEMS_ROUTES = [
'/__experimental/menu-items',
`rest_route=${ encodeURIComponent( '/__experimental/menu-items' ) }`,
];
-const REST_PAGES_ROUTES = [
- '/wp/v2/pages',
- `rest_route=${ encodeURIComponent( '/wp/v2/pages' ) }`,
-];
/**
* Determines if a given URL matches any of a given collection of
@@ -99,18 +80,6 @@ function assignMockMenuIds( menus ) {
: [];
}
-function createMockPages( pages ) {
- return pages.map( ( { title, slug }, index ) => ( {
- id: index + 1,
- type: 'page',
- link: `https://this/is/a/test/page/${ slug }`,
- title: {
- rendered: title,
- raw: title,
- },
- } ) );
-}
-
function getMenuMocks( responsesByMethod ) {
return getEndpointMocks( REST_MENUS_ROUTES, responsesByMethod );
}
@@ -119,10 +88,6 @@ function getMenuItemMocks( responsesByMethod ) {
return getEndpointMocks( REST_MENU_ITEMS_ROUTES, responsesByMethod );
}
-function getPagesMocks( responsesByMethod ) {
- return getEndpointMocks( REST_PAGES_ROUTES, responsesByMethod );
-}
-
async function visitNavigationEditor() {
const query = addQueryArgs( '', {
page: 'gutenberg-navigation',
@@ -143,8 +108,6 @@ describe( 'Navigation editor', () => {
} );
it( 'allows creation of a menu', async () => {
- const pagesResponse = createMockPages( pagesFixture );
-
const menuResponse = {
id: 4,
description: '',
@@ -158,7 +121,6 @@ describe( 'Navigation editor', () => {
await setUpResponseMocking( [
...getMenuMocks( { GET: [] } ),
...getMenuItemMocks( { GET: [] } ),
- ...getPagesMocks( { GET: pagesResponse } ),
] );
await visitNavigationEditor();
@@ -172,7 +134,6 @@ describe( 'Navigation editor', () => {
POST: menuResponse,
} ),
...getMenuItemMocks( { GET: [] } ),
- ...getPagesMocks( { GET: pagesResponse } ),
] );
// Add a new menu.
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 346c1cb42eba3a..3e8aa25dd641dc 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -119,6 +119,7 @@ export { default as moreVertical } from './library/more-vertical';
export { default as navigation } from './library/navigation';
export { default as pageBreak } from './library/page-break';
export { default as page } from './library/page';
+export { default as pages } from './library/pages';
export { default as paragraph } from './library/paragraph';
export { default as payment } from './library/payment';
export { default as percent } from './library/percent';
diff --git a/packages/icons/src/library/pages.js b/packages/icons/src/library/pages.js
new file mode 100644
index 00000000000000..7e7dff92cc71d6
--- /dev/null
+++ b/packages/icons/src/library/pages.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const pages = (
+
+);
+
+export default pages;