From 70f76490e3f3fe3dbba0b3c7f342db1b00cf21ee Mon Sep 17 00:00:00 2001 From: James Koster Date: Tue, 28 Nov 2023 15:56:39 +0000 Subject: [PATCH 01/23] Update trash icon (#56569) --- packages/icons/src/library/trash.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/icons/src/library/trash.js b/packages/icons/src/library/trash.js index 95a391ca1f6097..79870537dbb633 100644 --- a/packages/icons/src/library/trash.js +++ b/packages/icons/src/library/trash.js @@ -5,7 +5,11 @@ import { SVG, Path } from '@wordpress/primitives'; const trash = ( - + ); From 62f36532553404c4b3947c3d6e7e075b9906b30b Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 28 Nov 2023 16:07:59 +0000 Subject: [PATCH 02/23] Update: Refactor useAddedBy to use authorText and originalSource fields. (#56568) --- .../edit-site/src/components/list/added-by.js | 86 +++++-------------- 1 file changed, 23 insertions(+), 63 deletions(-) diff --git a/packages/edit-site/src/components/list/added-by.js b/packages/edit-site/src/components/list/added-by.js index e9c8df0fa7f263..a7ed2c4099547f 100644 --- a/packages/edit-site/src/components/list/added-by.js +++ b/packages/edit-site/src/components/list/added-by.js @@ -22,20 +22,10 @@ import { _x } from '@wordpress/i18n'; /** * Internal dependencies */ -import { - TEMPLATE_POST_TYPE, - TEMPLATE_PART_POST_TYPE, - TEMPLATE_ORIGINS, -} from '../../utils/constants'; +import { TEMPLATE_POST_TYPE, TEMPLATE_ORIGINS } from '../../utils/constants'; /** @typedef {'wp_template'|'wp_template_part'} TemplateType */ -/** @type {TemplateType} */ -const TEMPLATE_POST_TYPE_NAMES = [ - TEMPLATE_POST_TYPE, - TEMPLATE_PART_POST_TYPE, -]; - /** * @typedef {'theme'|'plugin'|'site'|'user'} AddedByType * @@ -55,8 +45,6 @@ export function useAddedBy( postType, postId ) { return useSelect( ( select ) => { const { - getTheme, - getPlugin, getEntityRecord, getMedia, getUser, @@ -67,82 +55,54 @@ export function useAddedBy( postType, postId ) { postType, postId ); + const originalSource = template?.original_source; + const authorText = template?.author_text; - if ( TEMPLATE_POST_TYPE_NAMES.includes( template.type ) ) { - // Added by theme. - // Template originally provided by a theme, but customized by a user. - // Templates originally didn't have the 'origin' field so identify - // older customized templates by checking for no origin and a 'theme' - // or 'custom' source. - if ( - template.has_theme_file && - ( template.origin === TEMPLATE_ORIGINS.theme || - ( ! template.origin && - [ - TEMPLATE_ORIGINS.theme, - TEMPLATE_ORIGINS.custom, - ].includes( template.source ) ) ) - ) { + switch ( originalSource ) { + case 'theme': { return { - type: 'theme', + type: originalSource, icon: themeIcon, - text: - getTheme( template.theme )?.name?.rendered || - template.theme, + text: authorText, isCustomized: template.source === TEMPLATE_ORIGINS.custom, }; } - - // Added by plugin. - if ( - template.has_theme_file && - template.origin === TEMPLATE_ORIGINS.plugin - ) { + case 'plugin': { return { - type: TEMPLATE_ORIGINS.plugin, + type: originalSource, icon: pluginIcon, - text: - getPlugin( template.theme )?.name || template.theme, + text: authorText, isCustomized: template.source === TEMPLATE_ORIGINS.custom, }; } - - // Added by site. - // Template was created from scratch, but has no author. Author support - // was only added to templates in WordPress 5.9. Fallback to showing the - // site logo and title. - if ( - ! template.has_theme_file && - template.source === TEMPLATE_ORIGINS.custom && - ! template.author - ) { + case 'site': { const siteData = getEntityRecord( 'root', '__unstableBase' ); return { - type: 'site', + type: originalSource, icon: globeIcon, imageUrl: siteData?.site_logo ? getMedia( siteData.site_logo )?.source_url : undefined, - text: siteData?.name, + text: authorText, + isCustomized: false, + }; + } + default: { + const user = getUser( template.author ); + return { + type: 'user', + icon: authorIcon, + imageUrl: user?.avatar_urls?.[ 48 ], + text: authorText, isCustomized: false, }; } } - - // Added by user. - const user = getUser( template.author ); - return { - type: 'user', - icon: authorIcon, - imageUrl: user?.avatar_urls?.[ 48 ], - text: user?.nickname, - isCustomized: false, - }; }, [ postType, postId ] ); From 41cee769313a31a59f72b5641e7c413b35521c0d Mon Sep 17 00:00:00 2001 From: Matias Benedetto Date: Tue, 28 Nov 2023 11:17:15 -0500 Subject: [PATCH 03/23] WP_Theme_JSON_Gutenberg: Add nested indexed array schema sanitization (#56447) * Add GB specific resolver * changing unset function * adding nested validation * rename function * Revert "Add GB specific resolver" This reverts commit 27c0f6f652cf7c69509074a65aebf00ec01d727e. * removing not needed check * removing not needed check * Remove keys from tree that are not arrays when they are defined as arrays in schema * remove key if it is empty * adding tests * adding function docs * php format * add comment to function --------- Co-authored-by: hellofromtonya --- lib/class-wp-theme-json-gutenberg.php | 131 +++++++++++++++++++++++--- phpunit/class-wp-theme-json-test.php | 130 +++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 12 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 646c7abc59eafe..9311001f2edd14 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -426,6 +426,31 @@ class WP_Theme_JSON_Gutenberg { ), ); + const FONT_FAMILY_SCHEMA = array( + array( + 'fontFamily' => null, + 'name' => null, + 'slug' => null, + 'fontFace' => array( + array( + 'ascentOverride' => null, + 'descentOverride' => null, + 'fontDisplay' => null, + 'fontFamily' => null, + 'fontFeatureSettings' => null, + 'fontStyle' => null, + 'fontStretch' => null, + 'fontVariationSettings' => null, + 'fontWeight' => null, + 'lineGapOverride' => null, + 'sizeAdjust' => null, + 'src' => null, + 'unicodeRange' => null, + ), + ), + ), + ); + /** * The valid properties under the styles key. * @@ -550,6 +575,52 @@ class WP_Theme_JSON_Gutenberg { 'typography' => 'typography', ); + /** + * Return the input schema at the root and per origin. + * + * @since 6.5.0 + * + * @param array $schema The base schema. + * @return array The schema at the root and per origin. + * + * Example: + * schema_in_root_and_per_origin( + * array( + * 'fontFamily' => null, + * 'slug' => null, + * ) + * ) + * + * Returns: + * array( + * 'fontFamily' => null, + * 'slug' => null, + * 'default' => array( + * 'fontFamily' => null, + * 'slug' => null, + * ), + * 'blocks' => array( + * 'fontFamily' => null, + * 'slug' => null, + * ), + * 'theme' => array( + * 'fontFamily' => null, + * 'slug' => null, + * ), + * 'custom' => array( + * 'fontFamily' => null, + * 'slug' => null, + * ), + * ) + */ + protected static function schema_in_root_and_per_origin( $schema ) { + $schema_in_root_and_per_origin = $schema; + foreach ( static::VALID_ORIGINS as $origin ) { + $schema_in_root_and_per_origin[ $origin ] = $schema; + } + return $schema_in_root_and_per_origin; + } + /** * Returns a class name by an element name. * @@ -791,11 +862,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_blocks[ $block ]['variations'] = $schema_styles_variations; } - $schema['styles'] = static::VALID_STYLES; - $schema['styles']['blocks'] = $schema_styles_blocks; - $schema['styles']['elements'] = $schema_styles_elements; - $schema['settings'] = static::VALID_SETTINGS; - $schema['settings']['blocks'] = $schema_settings_blocks; + $schema['styles'] = static::VALID_STYLES; + $schema['styles']['blocks'] = $schema_styles_blocks; + $schema['styles']['elements'] = $schema_styles_elements; + $schema['settings'] = static::VALID_SETTINGS; + $schema['settings']['blocks'] = $schema_settings_blocks; + $schema['settings']['typography']['fontFamilies'] = static::schema_in_root_and_per_origin( static::FONT_FAMILY_SCHEMA ); // Remove anything that's not present in the schema. foreach ( array( 'styles', 'settings' ) as $subtree ) { @@ -967,18 +1039,39 @@ protected static function get_blocks_metadata() { * @return array The modified $tree. */ protected static function remove_keys_not_in_schema( $tree, $schema ) { - $tree = array_intersect_key( $tree, $schema ); + if ( ! is_array( $tree ) ) { + return $tree; + } - foreach ( $schema as $key => $data ) { - if ( ! isset( $tree[ $key ] ) ) { + foreach ( $tree as $key => $value ) { + // Remove keys not in the schema or with null/empty values. + if ( ! array_key_exists( $key, $schema ) ) { + unset( $tree[ $key ] ); continue; } - if ( is_array( $schema[ $key ] ) && is_array( $tree[ $key ] ) ) { - $tree[ $key ] = static::remove_keys_not_in_schema( $tree[ $key ], $schema[ $key ] ); + // Check if the value is an array and requires further processing. + if ( is_array( $value ) && is_array( $schema[ $key ] ) ) { + // Determine if it is an associative or indexed array. + $schema_is_assoc = self::is_assoc( $value ); + + if ( $schema_is_assoc ) { + // If associative, process as a single object. + $tree[ $key ] = self::remove_keys_not_in_schema( $value, $schema[ $key ] ); - if ( empty( $tree[ $key ] ) ) { - unset( $tree[ $key ] ); + if ( empty( $tree[ $key ] ) ) { + unset( $tree[ $key ] ); + } + } else { + // If indexed, process each item in the array. + foreach ( $value as $item_key => $item_value ) { + if ( isset( $schema[ $key ][0] ) && is_array( $schema[ $key ][0] ) ) { + $tree[ $key ][ $item_key ] = self::remove_keys_not_in_schema( $item_value, $schema[ $key ][0] ); + } else { + // If the schema does not define a further structure, keep the value as is. + $tree[ $key ][ $item_key ] = $item_value; + } + } } } elseif ( is_array( $schema[ $key ] ) && ! is_array( $tree[ $key ] ) ) { unset( $tree[ $key ] ); @@ -988,6 +1081,20 @@ protected static function remove_keys_not_in_schema( $tree, $schema ) { return $tree; } + /** + * Checks if the given array is associative. + * + * @since 6.5.0 + * @param array $data The array to check. + * @return bool True if the array is associative, false otherwise. + */ + protected static function is_assoc( $data ) { + if ( array() === $data ) { + return false; + } + return array_keys( $data ) !== range( 0, count( $data ) - 1 ); + } + /** * Returns the existing settings for each block. * diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 07bec961553c9a..89900d45893d91 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1646,6 +1646,136 @@ public function data_sanitize_for_block_with_style_variations() { ); } + public function test_sanitize_indexed_arrays() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => '2', + 'badKey2' => 'I am Evil!!!!', + 'settings' => array( + 'badKey3' => 'I am Evil!!!!', + 'typography' => array( + 'badKey4' => 'I am Evil!!!!', + 'fontFamilies' => array( + 'custom' => array( + array( + 'badKey4' => 'I am Evil!!!!', + 'name' => 'Arial', + 'slug' => 'arial', + 'fontFamily' => 'Arial, sans-serif', + ), + ), + 'theme' => array( + array( + 'badKey5' => 'I am Evil!!!!', + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'badKey6' => 'I am Evil!!!!', + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + array( + 'badKey7' => 'I am Evil!!!!', + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + ), + ), + array( + 'badKey8' => 'I am Evil!!!!', + 'name' => 'Inter', + 'slug' => 'Inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'badKey9' => 'I am Evil!!!!', + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + array( + 'badKey10' => 'I am Evil!!!!', + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected_sanitized = array( + 'version' => '2', + 'settings' => array( + 'typography' => array( + 'fontFamilies' => array( + 'custom' => array( + array( + 'name' => 'Arial', + 'slug' => 'arial', + 'fontFamily' => 'Arial, sans-serif', + ), + ), + 'theme' => array( + array( + 'name' => 'Piazzolla', + 'slug' => 'piazzolla', + 'fontFamily' => 'Piazzolla', + 'fontFace' => array( + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + array( + 'fontFamily' => 'Piazzolla', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + ), + ), + array( + 'name' => 'Inter', + 'slug' => 'Inter', + 'fontFamily' => 'Inter', + 'fontFace' => array( + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + array( + 'fontFamily' => 'Inter', + 'fontStyle' => 'italic', + 'fontWeight' => '400', + 'src' => 'https://example.com/font.ttf', + ), + ), + ), + ), + ), + ), + ), + ); + $sanitized_theme_json = $theme_json->get_raw_data(); + $this->assertSameSetsWithIndex( $expected_sanitized, $sanitized_theme_json, 'Sanitized theme.json does not match' ); + } + /** * @dataProvider data_sanitize_with_invalid_style_variation * From 726b008bf8ebd14702324ba8998c87e21b62d266 Mon Sep 17 00:00:00 2001 From: Gerardo Pacheco Date: Tue, 28 Nov 2023 17:17:36 +0100 Subject: [PATCH 04/23] Mobile - Fix issue when backspacing in an empty Paragraph block (#56496) * Bring changes from #55134 to the mobile code * Mobile - RichText - Force focus when the block is selected but the textinput is not, for cases when merging blocks. * Update Buttons integration test due to a change in the logic of the app where deleting the only button available does not remove the block * Mobile - Heading block - Adds integration test for merging a Heading block with an empty Paragraph block * Mobile - Paragraph block - Adds integration test to check that backspacing in an empty Paragraph block merges succesfully with the previous block and keeps the focus on the TextInput * Mobile - RichText - Set selection values to be the last character position when merging and adds some comments to explain what is doing * Mobile - Paragraph block test - Use focusTextInput to check the TextInput is in focused instead of checking for the fomatting toolbar button * Rename shouldFocusTextInputAfterUpdate to shouldFocusTextInputAfterMerge * Update CHANGELOG --- .../src/components/block-list/block.native.js | 2 + .../src/components/rich-text/index.native.js | 2 +- .../rich-text/native/index.native.js | 47 ++++++++++++++++++- .../test/__snapshots__/edit.native.js.snap | 6 --- .../src/buttons/test/edit.native.js | 27 ----------- .../test/__snapshots__/index.native.js.snap | 6 +++ .../src/heading/test/index.native.js | 40 ++++++++++++++++ .../src/paragraph/test/edit.native.js | 38 ++++++++++++++- packages/react-native-editor/CHANGELOG.md | 1 + .../integration-test-helpers/add-block.js | 8 ++-- 10 files changed, 136 insertions(+), 41 deletions(-) diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index c6cce290985c22..03a84d530ba12a 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -605,6 +605,8 @@ const applyWithDispatch = withDispatch( ( dispatch, ownProps, registry ) => { } moveFirstItemUp( rootClientId ); + } else { + removeBlock( clientId ); } } }, diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index aab10e9ab65476..9427962eced198 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -223,7 +223,7 @@ function RichTextWrapper( // an intentional user interaction distinguishing between Backspace and // Delete to remove the empty field, but also to avoid merge & remove // causing destruction of two fields (merge, then removed merged). - if ( onRemove && isEmpty( value ) && isReverse ) { + else if ( onRemove && isEmpty( value ) && isReverse ) { onRemove( ! isReverse ); } }, diff --git a/packages/block-editor/src/components/rich-text/native/index.native.js b/packages/block-editor/src/components/rich-text/native/index.native.js index 2381b9809eca86..ab465b24411549 100644 --- a/packages/block-editor/src/components/rich-text/native/index.native.js +++ b/packages/block-editor/src/components/rich-text/native/index.native.js @@ -650,6 +650,40 @@ export class RichText extends Component { return shouldDrop; } + /** + * Determines whether the text input should receive focus after an update. + * For cases where a RichText with a value is merged with an empty one. + * + * @param {Object} prevProps - The previous props of the component. + * @return {boolean} True if the text input should receive focus, false otherwise. + */ + shouldFocusTextInputAfterMerge( prevProps ) { + const { + __unstableIsSelected: isSelected, + blockIsSelected, + selectionStart, + selectionEnd, + __unstableMobileNoFocusOnMount, + } = this.props; + + const { + __unstableIsSelected: prevIsSelected, + blockIsSelected: prevBlockIsSelected, + } = prevProps; + + const noSelectionValues = + selectionStart === undefined && selectionEnd === undefined; + const textInputWasNotFocused = ! prevIsSelected && ! isSelected; + + return ( + ! __unstableMobileNoFocusOnMount && + noSelectionValues && + textInputWasNotFocused && + ! prevBlockIsSelected && + blockIsSelected + ); + } + onSelectionChangeFromAztec( start, end, text, event ) { if ( this.shouldDropEventFromAztec( event, 'onSelectionChange' ) ) { return; @@ -843,9 +877,8 @@ export class RichText extends Component { if ( this.props.value !== this.value ) { this.value = this.props.value; } - const { __unstableIsSelected: isSelected } = this.props; - const { __unstableIsSelected: prevIsSelected } = prevProps; + const { __unstableIsSelected: isSelected } = this.props; if ( isSelected && ! prevIsSelected ) { this._editor.focus(); @@ -855,6 +888,16 @@ export class RichText extends Component { this.props.selectionStart || 0, this.props.selectionEnd || 0 ); + } else if ( this.shouldFocusTextInputAfterMerge( prevProps ) ) { + // Since this is happening when merging blocks, the selection should be at the last character position. + // As a fallback the internal selectionEnd value is used. + const lastCharacterPosition = + this.value?.length ?? this.selectionEnd; + this._editor.focus(); + this.props.onSelectionChange( + lastCharacterPosition, + lastCharacterPosition + ); } else if ( ! isSelected && prevIsSelected ) { this._editor.blur(); } diff --git a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap index 25867634d12d8e..1a55c807225d9d 100644 --- a/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap +++ b/packages/block-library/src/buttons/test/__snapshots__/edit.native.js.snap @@ -71,9 +71,3 @@ exports[`Buttons block when a button is shown removing button along with buttons

" `; - -exports[`Buttons block when a button is shown removing button along with buttons block removes the button and buttons block when deleting the block using the delete (backspace) key 1`] = ` -" -

-" -`; diff --git a/packages/block-library/src/buttons/test/edit.native.js b/packages/block-library/src/buttons/test/edit.native.js index 2fe70d034aa747..f393a31c7330ad 100644 --- a/packages/block-library/src/buttons/test/edit.native.js +++ b/packages/block-library/src/buttons/test/edit.native.js @@ -18,7 +18,6 @@ import { */ import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; -import { BACKSPACE } from '@wordpress/keycodes'; const BUTTONS_HTML = `
@@ -238,32 +237,6 @@ describe( 'Buttons block', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); - - it( 'removes the button and buttons block when deleting the block using the delete (backspace) key', async () => { - const screen = await initializeEditor( { - initialHtml: BUTTONS_HTML, - } ); - - // Get block - const buttonsBlock = await getBlock( screen, 'Buttons' ); - triggerBlockListLayout( buttonsBlock ); - - // Get inner button block - const buttonBlock = await getBlock( screen, 'Button' ); - fireEvent.press( buttonBlock ); - - const buttonInput = - within( buttonBlock ).getByLabelText( 'Text input. Empty' ); - - // Delete block - fireEvent( buttonInput, 'onKeyDown', { - nativeEvent: {}, - preventDefault() {}, - keyCode: BACKSPACE, - } ); - - expect( getEditorHtml() ).toMatchSnapshot(); - } ); } ); } ); diff --git a/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap b/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap index 308aa8ac729bff..c0397e823d4511 100644 --- a/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap +++ b/packages/block-library/src/heading/test/__snapshots__/index.native.js.snap @@ -6,6 +6,12 @@ exports[`Heading block inserts block 1`] = ` " `; +exports[`Heading block should merge with an empty Paragraph block and keep being the Heading block 1`] = ` +" +

A quick brown fox jumps over the lazy dog.

+" +`; + exports[`Heading block should set a background color 1`] = ` "

A quick brown fox jumps over the lazy dog.

diff --git a/packages/block-library/src/heading/test/index.native.js b/packages/block-library/src/heading/test/index.native.js index 5b7abbc91ad94a..1582e96aae0f4d 100644 --- a/packages/block-library/src/heading/test/index.native.js +++ b/packages/block-library/src/heading/test/index.native.js @@ -17,6 +17,7 @@ import { */ import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks'; import { registerCoreBlocks } from '@wordpress/block-library'; +import { BACKSPACE, ENTER } from '@wordpress/keycodes'; beforeAll( () => { // Register all core blocks @@ -134,4 +135,43 @@ describe( 'Heading block', () => { ) ).toBeVisible(); } ); + + it( 'should merge with an empty Paragraph block and keep being the Heading block', async () => { + // Arrange + const screen = await initializeEditor(); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + + const paragraphTextInput = + within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); + fireEvent( paragraphTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: ENTER, + } ); + + await addBlock( screen, 'Heading' ); + const headingBlock = getBlock( screen, 'Heading', { rowIndex: 2 } ); + fireEvent.press( headingBlock ); + + const headingTextInput = + within( headingBlock ).getByPlaceholderText( 'Heading' ); + typeInRichText( + headingTextInput, + 'A quick brown fox jumps over the lazy dog.', + { finalSelectionStart: 0, finalSelectionEnd: 0 } + ); + + fireEvent( headingTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: BACKSPACE, + } ); + + // Assert + expect( getEditorHtml() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/block-library/src/paragraph/test/edit.native.js b/packages/block-library/src/paragraph/test/edit.native.js index 8220ad0888c795..fdb082246171ba 100644 --- a/packages/block-library/src/paragraph/test/edit.native.js +++ b/packages/block-library/src/paragraph/test/edit.native.js @@ -17,11 +17,12 @@ import { waitForElementToBeRemoved, } from 'test/helpers'; import Clipboard from '@react-native-clipboard/clipboard'; +import TextInputState from 'react-native/Libraries/Components/TextInput/TextInputState'; /** * WordPress dependencies */ -import { ENTER } from '@wordpress/keycodes'; +import { BACKSPACE, ENTER } from '@wordpress/keycodes'; /** * Internal dependencies @@ -685,4 +686,39 @@ describe( 'Paragraph block', () => { " ` ); } ); + + it( 'should focus on the previous Paragraph block when backspacing in an empty Paragraph block', async () => { + // Arrange + const screen = await initializeEditor(); + await addBlock( screen, 'Paragraph' ); + + // Act + const paragraphBlock = getBlock( screen, 'Paragraph' ); + fireEvent.press( paragraphBlock ); + const paragraphTextInput = + within( paragraphBlock ).getByPlaceholderText( 'Start writing…' ); + typeInRichText( paragraphTextInput, 'A quick brown fox jumps' ); + + await addBlock( screen, 'Paragraph' ); + const secondParagraphBlock = getBlock( screen, 'Paragraph', { + rowIndex: 2, + } ); + fireEvent.press( secondParagraphBlock ); + + // Clear mock history + TextInputState.focusTextInput.mockClear(); + + const secondParagraphTextInput = + within( secondParagraphBlock ).getByPlaceholderText( + 'Start writing…' + ); + fireEvent( secondParagraphTextInput, 'onKeyDown', { + nativeEvent: {}, + preventDefault() {}, + keyCode: BACKSPACE, + } ); + + // Assert + expect( TextInputState.focusTextInput ).toHaveBeenCalled(); + } ); } ); diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 635937c4d8ce0b..4e509a232b3e52 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,7 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [***] Fix issue when backspacing in an empty Paragraph block [#56496] ## 1.109.0 - [*] Audio block: Improve legibility of audio file details on various background colors [#55627] diff --git a/test/native/integration-test-helpers/add-block.js b/test/native/integration-test-helpers/add-block.js index 5a15cb59fc6e16..eded603829c48a 100644 --- a/test/native/integration-test-helpers/add-block.js +++ b/test/native/integration-test-helpers/add-block.js @@ -6,7 +6,7 @@ import { Platform } from '@wordpress/element'; /** * External dependencies */ -import { act, fireEvent } from '@testing-library/react-native'; +import { act, fireEvent, within } from '@testing-library/react-native'; import { AccessibilityInfo } from 'react-native'; /** @@ -31,9 +31,9 @@ export const addBlock = async ( fireEvent.press( screen.getByLabelText( 'Add block' ) ); } - const blockList = screen.getByTestId( 'InserterUI-Blocks' ); + const inserterModal = screen.getByTestId( 'InserterUI-Blocks' ); // onScroll event used to force the FlatList to render all items - fireEvent.scroll( blockList, { + fireEvent.scroll( inserterModal, { nativeEvent: { contentOffset: { y: 0, x: 0 }, contentSize: { width: 100, height: 100 }, @@ -41,7 +41,7 @@ export const addBlock = async ( }, } ); - const blockButton = await screen.findByText( blockName ); + const blockButton = await within( inserterModal ).findByText( blockName ); // Blocks can perform belated state updates after they are inserted. // To avoid potential `act` warnings, we ensure that all timers and queued // microtasks are executed. From c6be294e157200537d91d3ab9e47de099202307b Mon Sep 17 00:00:00 2001 From: Alexandre Buffet <43843473+alexandrebuffet@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:28:34 +0100 Subject: [PATCH 05/23] Rich Text: Update the regex that checks the format of className in Format Types to allow underscores (#56599) --- packages/rich-text/src/register-format-type.js | 4 ++-- packages/rich-text/src/test/register-format-type.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rich-text/src/register-format-type.js b/packages/rich-text/src/register-format-type.js index b2dd048d79e6fb..c8aa45f022154c 100644 --- a/packages/rich-text/src/register-format-type.js +++ b/packages/rich-text/src/register-format-type.js @@ -71,9 +71,9 @@ export function registerFormatType( name, settings ) { return; } - if ( ! /^[_a-zA-Z]+[a-zA-Z0-9-]*$/.test( settings.className ) ) { + if ( ! /^[_a-zA-Z]+[a-zA-Z0-9_-]*$/.test( settings.className ) ) { window.console.error( - 'A class name must begin with a letter, followed by any number of hyphens, letters, or numbers.' + 'A class name must begin with a letter, followed by any number of hyphens, underscores, letters, or numbers.' ); return; } diff --git a/packages/rich-text/src/test/register-format-type.js b/packages/rich-text/src/test/register-format-type.js index 0f5c16eabf2323..a586e47945dd08 100644 --- a/packages/rich-text/src/test/register-format-type.js +++ b/packages/rich-text/src/test/register-format-type.js @@ -171,7 +171,7 @@ describe( 'registerFormatType', () => { className: 'invalid class name', } ); expect( console ).toHaveErroredWith( - 'A class name must begin with a letter, followed by any number of hyphens, letters, or numbers.' + 'A class name must begin with a letter, followed by any number of hyphens, underscores, letters, or numbers.' ); expect( format ).toBeUndefined(); } ); From ece0838e76238184a749bace6079f3810ddbc9d0 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:47:27 +0100 Subject: [PATCH 06/23] Raw handling: fix block schema merging (#56558) --- package-lock.json | 2 - packages/blocks/package.json | 1 - packages/blocks/src/api/raw-handling/utils.js | 94 ++++++++++--------- test/integration/blocks-raw-handling.test.js | 28 ++++++ 4 files changed, 78 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6eb7c8d9b96cca..ba125cefbbcb3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54775,7 +54775,6 @@ "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", - "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "hpq": "^1.3.0", "is-plain-object": "^5.0.0", @@ -70054,7 +70053,6 @@ "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", - "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "hpq": "^1.3.0", "is-plain-object": "^5.0.0", diff --git a/packages/blocks/package.json b/packages/blocks/package.json index abfed4b763e7e4..414e40ca9458e7 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -45,7 +45,6 @@ "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", - "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "hpq": "^1.3.0", "is-plain-object": "^5.0.0", diff --git a/packages/blocks/src/api/raw-handling/utils.js b/packages/blocks/src/api/raw-handling/utils.js index 76818f2663627d..3f4fe32a1af248 100644 --- a/packages/blocks/src/api/raw-handling/utils.js +++ b/packages/blocks/src/api/raw-handling/utils.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import deepmerge from 'deepmerge'; - /** * WordPress dependencies */ @@ -14,41 +9,6 @@ import { isPhrasingContent, getPhrasingContentSchema } from '@wordpress/dom'; import { hasBlockSupport } from '..'; import { getRawTransforms } from './get-raw-transforms'; -const customMerge = ( key ) => { - return ( srcValue, objValue ) => { - switch ( key ) { - case 'children': { - if ( objValue === '*' || srcValue === '*' ) { - return '*'; - } - - return { ...objValue, ...srcValue }; - } - case 'attributes': - case 'require': { - return [ ...( objValue || [] ), ...( srcValue || [] ) ]; - } - case 'isMatch': { - // If one of the values being merge is undefined (matches everything), - // the result of the merge will be undefined. - if ( ! objValue || ! srcValue ) { - return undefined; - } - // When merging two isMatch functions, the result is a new function - // that returns if one of the source functions returns true. - return ( ...args ) => { - return objValue( ...args ) || srcValue( ...args ); - }; - } - } - - return deepmerge( objValue, srcValue, { - customMerge, - clone: false, - } ); - }; -}; - export function getBlockContentSchemaFromTransforms( transforms, context ) { const phrasingContentSchema = getPhrasingContentSchema( context ); const schemaArgs = { phrasingContentSchema, isPaste: context === 'paste' }; @@ -86,10 +46,56 @@ export function getBlockContentSchemaFromTransforms( transforms, context ) { ); } ); - return deepmerge.all( schemas, { - customMerge, - clone: false, - } ); + function mergeTagNameSchemaProperties( objValue, srcValue, key ) { + switch ( key ) { + case 'children': { + if ( objValue === '*' || srcValue === '*' ) { + return '*'; + } + + return { ...objValue, ...srcValue }; + } + case 'attributes': + case 'require': { + return [ ...( objValue || [] ), ...( srcValue || [] ) ]; + } + case 'isMatch': { + // If one of the values being merge is undefined (matches everything), + // the result of the merge will be undefined. + if ( ! objValue || ! srcValue ) { + return undefined; + } + // When merging two isMatch functions, the result is a new function + // that returns if one of the source functions returns true. + return ( ...args ) => { + return objValue( ...args ) || srcValue( ...args ); + }; + } + } + } + + // A tagName schema is an object with children, attributes, require, and + // isMatch properties. + function mergeTagNameSchemas( a, b ) { + for ( const key in b ) { + a[ key ] = a[ key ] + ? mergeTagNameSchemaProperties( a[ key ], b[ key ], key ) + : { ...b[ key ] }; + } + return a; + } + + // A schema is an object with tagName schemas by tag name. + function mergeSchemas( a, b ) { + for ( const key in b ) { + a[ key ] = a[ key ] + ? mergeTagNameSchemas( a[ key ], b[ key ] ) + : { ...b[ key ] }; + } + return a; + } + + return schemas.reduce( mergeSchemas, {} ); } /** diff --git a/test/integration/blocks-raw-handling.test.js b/test/integration/blocks-raw-handling.test.js index 229fa0ba7761c8..8acfb052436ed7 100644 --- a/test/integration/blocks-raw-handling.test.js +++ b/test/integration/blocks-raw-handling.test.js @@ -369,6 +369,34 @@ describe( 'Blocks raw handling', () => { expect( console ).toHaveLogged(); } ); + it( 'should convert pre', () => { + const transformed = pasteHandler( { + HTML: '
1\n2
', + plainText: '1\n2', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( transformed ).toBe( + '
1\n2
' + ); + expect( console ).toHaveLogged(); + } ); + + it( 'should convert code', () => { + const transformed = pasteHandler( { + HTML: '
1\n2
', + plainText: '1\n2', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( transformed ).toBe( + '
1\n2
' + ); + expect( console ).toHaveLogged(); + } ); + describe( 'pasteHandler', () => { [ 'plain', From 0068e2a483b01d90be3d3c0523b58c3f8d91d98a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 28 Nov 2023 17:19:54 +0000 Subject: [PATCH 07/23] Dataviews: All Templates: Add filters to template author. (#56338) --- .../page-templates/dataviews-templates.js | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/edit-site/src/components/page-templates/dataviews-templates.js b/packages/edit-site/src/components/page-templates/dataviews-templates.js index 49e15dfe81c71a..c0999afa7ee3ff 100644 --- a/packages/edit-site/src/components/page-templates/dataviews-templates.js +++ b/packages/edit-site/src/components/page-templates/dataviews-templates.js @@ -31,6 +31,7 @@ import Link from '../routes/link'; import { useAddedBy, AvatarImage } from '../list/added-by'; import { TEMPLATE_POST_TYPE } from '../../utils/constants'; import { DataViews } from '../dataviews'; +import { ENUMERATION_TYPE, OPERATOR_IN } from '../dataviews/constants'; import { useResetTemplateAction, deleteTemplateAction, @@ -59,6 +60,7 @@ const DEFAULT_VIEW = { // better to keep track of the hidden ones. hiddenFields: [ 'preview' ], layout: {}, + filters: [], }; function normalizeSearchInput( input = '' ) { @@ -143,6 +145,20 @@ export default function DataviewsTemplates() { per_page: -1, } ); + const authors = useMemo( () => { + if ( ! allTemplates ) { + return EMPTY_ARRAY; + } + const authorsSet = new Set(); + allTemplates.forEach( ( template ) => { + authorsSet.add( template.author_text ); + } ); + return Array.from( authorsSet ).map( ( author ) => ( { + value: author, + label: author, + } ) ); + }, [ allTemplates ] ); + const fields = useMemo( () => [ { @@ -192,9 +208,11 @@ export default function DataviewsTemplates() { return ; }, enableHiding: false, + type: ENUMERATION_TYPE, + elements: authors, }, ], - [] + [ authors ] ); const { shownTemplates, paginationInfo } = useMemo( () => { @@ -221,6 +239,21 @@ export default function DataviewsTemplates() { } ); } + // Handle filters. + if ( view.filters.length > 0 ) { + view.filters.forEach( ( filter ) => { + if ( + filter.field === 'author' && + filter.operator === OPERATOR_IN && + !! filter.value + ) { + filteredTemplates = filteredTemplates.filter( ( item ) => { + return item.author_text === filter.value; + } ); + } + } ); + } + // Handle sorting. if ( view.sort ) { const stringSortingFields = [ 'title', 'author' ]; From 4494a79e24bfbbcda97ce9af5db3dcb9e81b09f6 Mon Sep 17 00:00:00 2001 From: JuanMa Date: Tue, 28 Nov 2023 18:56:01 +0100 Subject: [PATCH 08/23] Fundamentals block development - landing and first pages (#56584) * Add fundamentals of block development documentation * Remove JavaScript how-to guides and update block development fundamentals landing * Update block development fundamentals documentation * Manifest and TOC * Update block development documentation links * Update block development documentation links * restored how-to-guides javascript * Update TOC * update folder name of fundamentals section --- docs/getting-started/fundamentals/README.md | 9 +++++++ .../file-structure-of-a-block.md | 0 .../javascript-in-the-block-editor.md | 0 .../registration-of-a-block.md | 0 docs/manifest.json | 24 +++++++++++++++++++ .../block-api/block-metadata.md | 2 +- docs/toc.json | 13 ++++++++++ 7 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 docs/getting-started/fundamentals/README.md rename docs/getting-started/{fundamentals-block-development => fundamentals}/file-structure-of-a-block.md (100%) rename docs/getting-started/{fundamentals-block-development => fundamentals}/javascript-in-the-block-editor.md (100%) rename docs/getting-started/{fundamentals-block-development => fundamentals}/registration-of-a-block.md (100%) diff --git a/docs/getting-started/fundamentals/README.md b/docs/getting-started/fundamentals/README.md new file mode 100644 index 00000000000000..fab96d51f7397e --- /dev/null +++ b/docs/getting-started/fundamentals/README.md @@ -0,0 +1,9 @@ +# Fundamentals of Block Development + +This section provides an introduction to the most important concepts in Block Development. + +In this section, you will learn: + +1. [**File structure of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/file-structure-of-a-block) - The purpose of each one of the types of files available for a block, the relationships between them, and their role in the output of the block. +1. [**Registration of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How a block is registered in both the server and the client. +1. [**Javascript in the Block Editor**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/javascript-in-the-block-editor) - How to work with Javascript for the Block Editor. \ No newline at end of file diff --git a/docs/getting-started/fundamentals-block-development/file-structure-of-a-block.md b/docs/getting-started/fundamentals/file-structure-of-a-block.md similarity index 100% rename from docs/getting-started/fundamentals-block-development/file-structure-of-a-block.md rename to docs/getting-started/fundamentals/file-structure-of-a-block.md diff --git a/docs/getting-started/fundamentals-block-development/javascript-in-the-block-editor.md b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md similarity index 100% rename from docs/getting-started/fundamentals-block-development/javascript-in-the-block-editor.md rename to docs/getting-started/fundamentals/javascript-in-the-block-editor.md diff --git a/docs/getting-started/fundamentals-block-development/registration-of-a-block.md b/docs/getting-started/fundamentals/registration-of-a-block.md similarity index 100% rename from docs/getting-started/fundamentals-block-development/registration-of-a-block.md rename to docs/getting-started/fundamentals/registration-of-a-block.md diff --git a/docs/manifest.json b/docs/manifest.json index 5906743512062c..849f1caf23f6c2 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -95,6 +95,30 @@ "markdown_source": "../docs/getting-started/create-block/submitting-to-block-directory.md", "parent": "create-block" }, + { + "title": "Fundamentals of Block Development", + "slug": "fundamentals", + "markdown_source": "../docs/getting-started/fundamentals/README.md", + "parent": "getting-started" + }, + { + "title": "File structure of a block", + "slug": "file-structure-of-a-block", + "markdown_source": "../docs/getting-started/fundamentals/file-structure-of-a-block.md", + "parent": "fundamentals" + }, + { + "title": "Registration of a block", + "slug": "registration-of-a-block", + "markdown_source": "../docs/getting-started/fundamentals/registration-of-a-block.md", + "parent": "fundamentals" + }, + { + "title": "Working with Javascript for the Block Editor", + "slug": "javascript-in-the-block-editor", + "markdown_source": "../docs/getting-started/fundamentals/javascript-in-the-block-editor.md", + "parent": "fundamentals" + }, { "title": "Glossary", "slug": "glossary", diff --git a/docs/reference-guides/block-api/block-metadata.md b/docs/reference-guides/block-api/block-metadata.md index a91dfd747fa24b..edc61d138128e6 100644 --- a/docs/reference-guides/block-api/block-metadata.md +++ b/docs/reference-guides/block-api/block-metadata.md @@ -78,7 +78,7 @@ Development is improved by using a defined schema definition file. Supported edi ```
-Check Registration of a block to learn more about how to register a block using its metadata. +Check Registration of a block to learn more about how to register a block using its metadata.
## Block API diff --git a/docs/toc.json b/docs/toc.json index 8a29d2d4f10aff..834bab31048454 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -46,6 +46,19 @@ } ] }, + { + "docs/getting-started/fundamentals/README.md": [ + { + "docs/getting-started/fundamentals/file-structure-of-a-block.md": [] + }, + { + "docs/getting-started/fundamentals/registration-of-a-block.md": [] + }, + { + "docs/getting-started/fundamentals/javascript-in-the-block-editor.md": [] + } + ] + }, { "docs/getting-started/glossary.md": [] }, { "docs/getting-started/faq.md": [] } ] From 6d9740f13f8232958b4c0e48fb120c189b216278 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Tue, 28 Nov 2023 19:02:27 +0100 Subject: [PATCH 09/23] Blocks pkg: remove 'browser' dependencies (#56433) --- packages/blocks/src/api/raw-handling/image-corrector.js | 7 +------ .../blocks/src/api/raw-handling/ms-list-converter.js | 5 ----- packages/blocks/src/api/raw-handling/paste-handler.js | 9 +++------ packages/blocks/src/store/process-block-type.js | 3 ++- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/blocks/src/api/raw-handling/image-corrector.js b/packages/blocks/src/api/raw-handling/image-corrector.js index d8de5e3a2ff50d..d3cf2ffb17486f 100644 --- a/packages/blocks/src/api/raw-handling/image-corrector.js +++ b/packages/blocks/src/api/raw-handling/image-corrector.js @@ -3,11 +3,6 @@ */ import { createBlobURL } from '@wordpress/blob'; -/** - * Browser dependencies - */ -const { atob, File } = window; - export default function imageCorrector( node ) { if ( node.nodeName !== 'IMG' ) { return; @@ -44,7 +39,7 @@ export default function imageCorrector( node ) { } const name = type.replace( '/', '.' ); - const file = new File( [ uint8Array ], name, { type } ); + const file = new window.File( [ uint8Array ], name, { type } ); node.src = createBlobURL( file ); } diff --git a/packages/blocks/src/api/raw-handling/ms-list-converter.js b/packages/blocks/src/api/raw-handling/ms-list-converter.js index fdbc48398a1cc6..03db53edc772ac 100644 --- a/packages/blocks/src/api/raw-handling/ms-list-converter.js +++ b/packages/blocks/src/api/raw-handling/ms-list-converter.js @@ -1,8 +1,3 @@ -/** - * Browser dependencies - */ -const { parseInt } = window; - /** * Internal dependencies */ diff --git a/packages/blocks/src/api/raw-handling/paste-handler.js b/packages/blocks/src/api/raw-handling/paste-handler.js index 2f68a826931ab6..d0bf3e05979c63 100644 --- a/packages/blocks/src/api/raw-handling/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/paste-handler.js @@ -33,10 +33,7 @@ import { deepFilterHTML, isPlain, getBlockContentSchema } from './utils'; import emptyParagraphRemover from './empty-paragraph-remover'; import slackParagraphCorrector from './slack-paragraph-corrector'; -/** - * Browser dependencies - */ -const { console } = window; +const log = ( ...args ) => window?.console?.log?.( ...args ); /** * Filters HTML to only contain phrasing content. @@ -60,7 +57,7 @@ function filterInlineHTML( HTML ) { HTML = deepFilterHTML( HTML, [ htmlFormattingRemover, brRemover ] ); // Allows us to ask for this information when we get a report. - console.log( 'Processed inline HTML:\n\n', HTML ); + log( 'Processed inline HTML:\n\n', HTML ); return HTML; } @@ -214,7 +211,7 @@ export function pasteHandler( { ); // Allows us to ask for this information when we get a report. - console.log( 'Processed HTML piece:\n\n', piece ); + log( 'Processed HTML piece:\n\n', piece ); return htmlToBlocks( piece, pasteHandler ); } ) diff --git a/packages/blocks/src/store/process-block-type.js b/packages/blocks/src/store/process-block-type.js index d69f9f8e5810fe..59b48979b07eb5 100644 --- a/packages/blocks/src/store/process-block-type.js +++ b/packages/blocks/src/store/process-block-type.js @@ -17,7 +17,8 @@ import { BLOCK_ICON_DEFAULT, DEPRECATED_ENTRY_KEYS } from '../api/constants'; /** @typedef {import('../api/registration').WPBlockType} WPBlockType */ -const { error, warn } = window.console; +const error = ( ...args ) => window?.console?.error?.( ...args ); +const warn = ( ...args ) => window?.console?.warn?.( ...args ); /** * Mapping of legacy category slugs to their latest normal values, used to From 53ffc0b1ba97ca13355cfd81be3b85b58d7d9920 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Tue, 28 Nov 2023 19:22:38 +0100 Subject: [PATCH 10/23] Interactivity API: migration to the new `store()` API (#55459) * Copy new `store()` implementation * Enable TS for the interactivity API * Update directive bind tests * Update runtime with latest changes * Replace wp-effect tests with wp-watch tests * Update wp-body tests * Update wp-class tests * Update wp-context tests * Update wp-init tests * Fix wp-key assignment * Update wp-key tests * Update wp-on tests * Update directive priorities tests * Update directive slot tests * Update wp-style tests * Update wp-text tests * Update negation operator tests * Update router navigate tests * Refactor tests for router regions * Removed afterLoad tests * Fix initial state tag id * Update initial state tests * Update tovdom-islands tests * Update tovdom tests * Replace store with initial state in PHP * Fix wp prefixes in initial state * Add types declaration to package.json so that TS works * Add `wp-each` directive in the new store API * Add workaround for store API SDR * Modify children instead of replacing the element * Migrate File block to the new store API * Refactor Image block to use the new store API * Update runtime * Fix file namespace * Finish migrating the Image block * Update Navigation block * Replace effect with watch in Image * Fix namespace assignment * Fix image state getter names * Fix getters in navigation state * Update Query block * Fix directives for Navigation block * Add missing store namespace * Allow forward slashes in namespaces * Migrate Search block * Update Initial State and its tests * Revert "Add `wp-each` directive in the new store API" This reverts commit 51bf723490556445073f0874939a4e88429c9dff. * Revert "Update Initial State and its tests" This reverts commit 7e5580c79f13a8f3580c2f40fdbdce65a1acfc13. * Revert "Replace store with initial state in PHP" This reverts commit 519880f8cd06e527f6fd50a91725fa641e29f8ac. * Revert "Add workaround for store API SDR" This reverts commit 933c4aca997926cf01cbace7ba864b2e554be9ec. * Fix phpcs errors * Update Query phpunit tests * Fix Query's Interactivity API context definition * Fix action name in Navigation * Make `double()` prop a getter Co-authored-by: Luis Herranz * Fix `double()` prop definition * Rename effects to callbacks in tests * Rename effects to callbacks in blocks * Rename effects to callbacks in unit tests * Move callbacks at the end of the store definition * Remove `layout-init` directive * Add default args for `openMenu` and `closeMenu` * Change Interactivity Store script tag ID --------- Co-authored-by: Michal Czaplinski Co-authored-by: Mario Santos Co-authored-by: Luis Herranz --- .../class-wp-navigation-block-renderer.php | 38 +- .../class-wp-interactivity-store.php | 2 +- packages/block-library/src/file/index.php | 4 +- packages/block-library/src/file/view.js | 12 +- packages/block-library/src/image/index.php | 80 ++- packages/block-library/src/image/view.js | 612 ++++++++---------- .../block-library/src/navigation/index.php | 20 +- packages/block-library/src/navigation/view.js | 351 +++++----- .../src/query-pagination-next/index.php | 6 +- .../src/query-pagination-numbers/index.php | 2 +- .../src/query-pagination-previous/index.php | 6 +- packages/block-library/src/query/index.php | 16 +- packages/block-library/src/query/view.js | 123 ++-- packages/block-library/src/search/index.php | 24 +- packages/block-library/src/search/view.js | 121 ++-- packages/block-library/tsconfig.json | 1 + .../directive-bind/render.php | 2 +- .../interactive-blocks/directive-bind/view.js | 9 +- .../directive-body/render.php | 2 +- .../interactive-blocks/directive-body/view.js | 7 +- .../directive-class/render.php | 2 +- .../directive-class/view.js | 11 +- .../directive-context/render.php | 8 +- .../directive-context/view.js | 66 +- .../directive-effect/block.json | 14 - .../directive-init/render.php | 12 +- .../interactive-blocks/directive-init/view.js | 51 +- .../directive-key/render.php | 5 +- .../interactive-blocks/directive-key/view.js | 9 +- .../directive-on/render.php | 2 +- .../interactive-blocks/directive-on/view.js | 14 +- .../directive-priorities/render.php | 7 +- .../directive-priorities/view.js | 55 +- .../directive-slots/render.php | 2 +- .../directive-slots/view.js | 9 +- .../directive-style/render.php | 2 +- .../directive-style/view.js | 11 +- .../directive-text/render.php | 2 +- .../interactive-blocks/directive-text/view.js | 9 +- .../block.json | 6 +- .../render.php | 12 +- .../view.js | 30 +- .../negation-operator/render.php | 4 +- .../negation-operator/view.js | 12 +- .../router-navigate/render.php | 17 +- .../router-navigate/view.js | 40 +- .../router-regions/render.php | 15 +- .../interactive-blocks/router-regions/view.js | 20 +- .../store-afterload/render.php | 41 -- .../store-afterload/view.js | 60 -- .../interactive-blocks/store-tag/render.php | 8 +- .../interactive-blocks/store-tag/view.js | 8 +- .../tovdom-islands/render.php | 15 +- .../interactive-blocks/tovdom-islands/view.js | 19 +- .../interactive-blocks/tovdom/render.php | 2 +- .../plugins/interactive-blocks/tovdom/view.js | 2 +- packages/interactivity/package.json | 1 + packages/interactivity/src/directives.js | 342 +++++----- .../interactivity/src/{hooks.js => hooks.tsx} | 142 +++- packages/interactivity/src/index.js | 5 +- packages/interactivity/src/store.js | 102 --- packages/interactivity/src/store.ts | 289 +++++++++ packages/interactivity/src/vdom.js | 45 +- packages/interactivity/tsconfig.json | 10 + phpunit/blocks/render-query-test.php | 24 +- .../class-wp-interactivity-store-test.php | 4 +- ...effect.spec.ts => directive-watch.spec.ts} | 10 +- .../interactivity/store-afterload.spec.ts | 40 -- tools/webpack/interactivity.js | 4 + tsconfig.json | 1 + 70 files changed, 1573 insertions(+), 1486 deletions(-) delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json rename packages/e2e-tests/plugins/interactive-blocks/{store-afterload => directive-watch}/block.json (60%) rename packages/e2e-tests/plugins/interactive-blocks/{directive-effect => directive-watch}/render.php (62%) rename packages/e2e-tests/plugins/interactive-blocks/{directive-effect => directive-watch}/view.js (65%) delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php delete mode 100644 packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js rename packages/interactivity/src/{hooks.js => hooks.tsx} (63%) delete mode 100644 packages/interactivity/src/store.js create mode 100644 packages/interactivity/src/store.ts create mode 100644 packages/interactivity/tsconfig.json rename test/e2e/specs/interactivity/{directive-effect.spec.ts => directive-watch.spec.ts} (79%) delete mode 100644 test/e2e/specs/interactivity/store-afterload.spec.ts diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index e2eb4e10414fe8..52ec4f508246ac 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -429,25 +429,25 @@ private static function get_responsive_container_markup( $attributes, $inner_blo $close_button_directives = ''; if ( $should_load_view_script ) { $open_button_directives = ' - data-wp-on--click="actions.core.navigation.openMenuOnClick" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + data-wp-on--click="actions.openMenuOnClick" + data-wp-on--keydown="actions.handleMenuKeydown" '; $responsive_container_directives = ' - data-wp-class--has-modal-open="selectors.core.navigation.isMenuOpen" - data-wp-class--is-menu-open="selectors.core.navigation.isMenuOpen" - data-wp-effect="effects.core.navigation.initMenu" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" - data-wp-on--focusout="actions.core.navigation.handleMenuFocusout" + data-wp-class--has-modal-open="state.isMenuOpen" + data-wp-class--is-menu-open="state.isMenuOpen" + data-wp-watch="callbacks.initMenu" + data-wp-on--keydown="actions.handleMenuKeydown" + data-wp-on--focusout="actions.handleMenuFocusout" tabindex="-1" '; $responsive_dialog_directives = ' - data-wp-bind--aria-modal="selectors.core.navigation.ariaModal" - data-wp-bind--aria-label="selectors.core.navigation.ariaLabel" - data-wp-bind--role="selectors.core.navigation.roleAttribute" - data-wp-effect="effects.core.navigation.focusFirstElement" + data-wp-bind--aria-modal="state.ariaModal" + data-wp-bind--aria-label="state.ariaLabel" + data-wp-bind--role="state.roleAttribute" + data-wp-watch="callbacks.focusFirstElement" '; $close_button_directives = ' - data-wp-on--click="actions.core.navigation.closeMenuOnClick" + data-wp-on--click="actions.closeMenuOnClick" '; } @@ -521,19 +521,15 @@ private static function get_nav_element_directives( $should_load_view_script ) { // When adding to this array be mindful of security concerns. $nav_element_context = wp_json_encode( array( - 'core' => array( - 'navigation' => array( - 'overlayOpenedBy' => array(), - 'type' => 'overlay', - 'roleAttribute' => '', - 'ariaLabel' => __( 'Menu' ), - ), - ), + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ); return ' - data-wp-interactive + data-wp-interactive=\'{"namespace":"core/navigation"}\' data-wp-context=\'' . $nav_element_context . '\' '; } diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php index 89cb58700554a9..c53701b14e8aff 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-store.php @@ -62,7 +62,7 @@ public static function render() { return; } echo sprintf( - '', + '', wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) ); } diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 042ea899707360..8bcce69ef8968d 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -57,9 +57,9 @@ static function ( $matches ) { if ( $should_load_view_script ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); - $processor->set_attribute( 'data-wp-interactive', '' ); + $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core/file"}' ); $processor->next_tag( 'object' ); - $processor->set_attribute( 'data-wp-bind--hidden', '!selectors.core.file.hasPdfPreview' ); + $processor->set_attribute( 'data-wp-bind--hidden', '!state.hasPdfPreview' ); $processor->set_attribute( 'hidden', true ); return $processor->get_updated_html(); } diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index 9d09ca2b7f4340..79340223f007cb 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -5,14 +5,12 @@ import { store } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { browserSupportsPdfs as hasPdfPreview } from './utils'; +import { browserSupportsPdfs } from './utils'; -store( { - selectors: { - core: { - file: { - hasPdfPreview, - }, +store( 'core/file', { + state: { + get hasPdfPreview() { + return browserSupportsPdfs(); }, }, } ); diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index acefd5714bbd47..5667c71c45affc 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -187,27 +187,23 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); - $w->set_attribute( 'data-wp-interactive', true ); + $w->set_attribute( 'data-wp-interactive', '{"namespace":"core/image"}' ); $w->set_attribute( 'data-wp-context', sprintf( - '{ "core": - { "image": - { "imageLoaded": false, - "initialized": false, - "lightboxEnabled": false, - "hideAnimationEnabled": false, - "preloadInitialized": false, - "lightboxAnimation": "%s", - "imageUploadedSrc": "%s", - "imageCurrentSrc": "", - "targetWidth": "%s", - "targetHeight": "%s", - "scaleAttr": "%s", - "dialogLabel": "%s" - } - } + '{ "imageLoaded": false, + "initialized": false, + "lightboxEnabled": false, + "hideAnimationEnabled": false, + "preloadInitialized": false, + "lightboxAnimation": "%s", + "imageUploadedSrc": "%s", + "imageCurrentSrc": "", + "targetWidth": "%s", + "targetHeight": "%s", + "scaleAttr": "%s", + "dialogLabel": "%s" }', $lightbox_animation, $img_uploaded_src, @@ -218,14 +214,14 @@ function block_core_image_render_lightbox( $block_content, $block ) { ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-init', 'effects.core.image.initOriginImage' ); - $w->set_attribute( 'data-wp-on--load', 'actions.core.image.handleLoad' ); - $w->set_attribute( 'data-wp-effect', 'effects.core.image.setButtonStyles' ); + $w->set_attribute( 'data-wp-init', 'callbacks.initOriginImage' ); + $w->set_attribute( 'data-wp-on--load', 'actions.handleLoad' ); + $w->set_attribute( 'data-wp-watch', 'callbacks.setButtonStyles' ); // We need to set an event callback on the `img` specifically // because the `figure` element can also contain a caption, and // we don't want to trigger the lightbox when the caption is clicked. - $w->set_attribute( 'data-wp-on--click', 'actions.core.image.showLightbox' ); - $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.core.image.setStylesOnResize' ); + $w->set_attribute( 'data-wp-on--click', 'actions.showLightbox' ); + $w->set_attribute( 'data-wp-watch--setStylesOnResize', 'callbacks.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Add a button alongside image in the body content. @@ -239,9 +235,9 @@ class="lightbox-trigger" type="button" aria-haspopup="dialog" aria-label="' . esc_attr( $aria_label ) . '" - data-wp-on--click="actions.core.image.showLightbox" - data-wp-style--right="context.core.image.imageButtonRight" - data-wp-style--top="context.core.image.imageButtonTop" + data-wp-on--click="actions.showLightbox" + data-wp-style--right="context.imageButtonRight" + data-wp-style--top="context.imageButtonTop" > @@ -267,8 +263,8 @@ class="lightbox-trigger" // use the exact same image as in the content when the lightbox is first opened while // we wait for the larger image to load. $m->set_attribute( 'src', '' ); - $m->set_attribute( 'data-wp-bind--src', 'context.core.image.imageCurrentSrc' ); - $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $m->set_attribute( 'data-wp-bind--src', 'context.imageCurrentSrc' ); + $m->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); $q = new WP_HTML_Tag_Processor( $block_content ); @@ -283,8 +279,8 @@ class="lightbox-trigger" // and Chrome (see https://github.com/WordPress/gutenberg/pull/52765#issuecomment-1674008151). Until that // is resolved, manually setting the 'src' seems to be the best solution to load the large image on demand. $q->set_attribute( 'src', '' ); - $q->set_attribute( 'data-wp-bind--src', 'selectors.core.image.enlargedImgSrc' ); - $q->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $q->set_attribute( 'data-wp-bind--src', 'state.enlargedImgSrc' ); + $q->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $enlarged_image_content = $q->get_updated_html(); // If the current theme does NOT have a `theme.json`, or the colors are not defined, @@ -307,21 +303,21 @@ class="lightbox-trigger" $lightbox_html = << - diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 74ed649a0df126..315ed995f26cfc 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -17,7 +17,7 @@ const focusableSelectors = [ '[tabindex]:not([tabindex^="-"])', ]; -/* +/** * Stores a context-bound scroll handler. * * This callback could be defined inline inside of the store @@ -32,7 +32,7 @@ const focusableSelectors = [ */ let scrollCallback; -/* +/** * Tracks whether user is touching screen; used to * differentiate behavior for touch and mouse input. * @@ -40,7 +40,7 @@ let scrollCallback; */ let isTouching = false; -/* +/** * Tracks the last time the screen was touched; used to * differentiate behavior for touch and mouse input. * @@ -48,7 +48,7 @@ let isTouching = false; */ let lastTouchTime = 0; -/* +/** * Lightbox page-scroll handler: prevents scrolling. * * This handler is added to prevent scrolling behaviors that @@ -64,348 +64,296 @@ let lastTouchTime = 0; * instead to not rely on JavaScript, but this seems to be the best approach * for now that provides the best visual experience. * - * @param {Object} context Interactivity page context? + * @param {Object} ctx Context object with the `core/image` namespace. */ -function handleScroll( context ) { +function handleScroll( ctx ) { // We can't override the scroll behavior on mobile devices // because doing so breaks the pinch to zoom functionality, and we // want to allow users to zoom in further on the high-res image. if ( ! isTouching && Date.now() - lastTouchTime > 450 ) { // We are unable to use event.preventDefault() to prevent scrolling // because the scroll event can't be canceled, so we reset the position instead. - window.scrollTo( - context.core.image.scrollLeftReset, - context.core.image.scrollTopReset - ); + window.scrollTo( ctx.scrollLeftReset, ctx.scrollTopReset ); } } -store( - { - state: { - core: { - image: { - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - }, - }, +const { state, actions, callbacks } = store( 'core/image', { + state: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + get roleAttribute() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'dialog' : null; }, - actions: { - core: { - image: { - showLightbox: ( { context, event } ) => { - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - if ( ! context.core.image.imageLoaded ) { - return; - } - context.core.image.initialized = true; - context.core.image.lastFocusedElement = - window.document.activeElement; - context.core.image.scrollDelta = 0; - context.core.image.pointerType = event.pointerType; - - context.core.image.lightboxEnabled = true; - setStyles( context, context.core.image.imageRef ); - - context.core.image.scrollTopReset = - window.pageYOffset || - document.documentElement.scrollTop; - - // In most cases, this value will be 0, but this is included - // in case a user has created a page with horizontal scrolling. - context.core.image.scrollLeftReset = - window.pageXOffset || - document.documentElement.scrollLeft; - - // We define and bind the scroll callback here so - // that we can pass the context and as an argument. - // We may be able to change this in the future if we - // define the scroll callback in the store instead, but - // this approach seems to tbe clearest for now. - scrollCallback = handleScroll.bind( null, context ); - - // We need to add a scroll event listener to the window - // here because we are unable to otherwise access it via - // the Interactivity API directives. If we add a native way - // to access the window, we can remove this. - window.addEventListener( - 'scroll', - scrollCallback, - false - ); - }, - hideLightbox: async ( { context } ) => { - context.core.image.hideAnimationEnabled = true; - if ( context.core.image.lightboxEnabled ) { - // We want to wait until the close animation is completed - // before allowing a user to scroll again. The duration of this - // animation is defined in the styles.scss and depends on if the - // animation is 'zoom' or 'fade', but in any case we should wait - // a few milliseconds longer than the duration, otherwise a user - // may scroll too soon and cause the animation to look sloppy. - setTimeout( function () { - window.removeEventListener( - 'scroll', - scrollCallback - ); - // If we don't delay before changing the focus, - // the focus ring will appear on Firefox before - // the image has finished animating, which looks broken. - context.core.image.lightboxTriggerRef.focus( { - preventScroll: true, - } ); - }, 450 ); - - context.core.image.lightboxEnabled = false; - } - }, - handleKeydown: ( { context, actions, event } ) => { - if ( context.core.image.lightboxEnabled ) { - if ( event.key === 'Tab' || event.keyCode === 9 ) { - // If shift + tab it change the direction - if ( - event.shiftKey && - window.document.activeElement === - context.core.image.firstFocusableElement - ) { - event.preventDefault(); - context.core.image.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.image.lastFocusableElement - ) { - event.preventDefault(); - context.core.image.firstFocusableElement.focus(); - } - } - - if ( - event.key === 'Escape' || - event.keyCode === 27 - ) { - actions.core.image.hideLightbox( { - context, - event, - } ); - } - } - }, - // This is fired just by lazily loaded - // images on the page, not all images. - handleLoad: ( { context, effects, ref } ) => { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - effects.core.image.setButtonStyles( { - context, - ref, - } ); - }, - handleTouchStart: () => { - isTouching = true; - }, - handleTouchMove: ( { context, event } ) => { - // On mobile devices, we want to prevent triggering the - // scroll event because otherwise the page jumps around as - // we reset the scroll position. This also means that closing - // the lightbox requires that a user perform a simple tap. This - // may be changed in the future if we find a better alternative - // to override or reset the scroll position during swipe actions. - if ( context.core.image.lightboxEnabled ) { - event.preventDefault(); - } - }, - handleTouchEnd: () => { - // We need to wait a few milliseconds before resetting - // to ensure that pinch to zoom works consistently - // on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); - isTouching = false; - }, - }, - }, + get ariaModal() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'true' : null; }, - selectors: { - core: { - image: { - roleAttribute: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'dialog' - : null; - }, - ariaModal: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'true' - : null; - }, - dialogLabel: ( { context } ) => { - return context.core.image.lightboxEnabled - ? context.core.image.dialogLabel - : null; - }, - lightboxObjectFit: ( { context } ) => { - if ( context.core.image.initialized ) { - return 'cover'; - } - }, - enlargedImgSrc: ( { context } ) => { - return context.core.image.initialized - ? context.core.image.imageUploadedSrc - : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - }, - }, - }, + get dialogLabel() { + const ctx = getContext(); + return ctx.lightboxEnabled ? ctx.dialogLabel : null; }, - effects: { - core: { - image: { - initOriginImage: ( { context, ref } ) => { - context.core.image.imageRef = ref; - context.core.image.lightboxTriggerRef = - ref.parentElement.querySelector( - '.lightbox-trigger' - ); - if ( ref.complete ) { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - } - }, - initLightbox: async ( { context, ref } ) => { - if ( context.core.image.lightboxEnabled ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.image.firstFocusableElement = - focusableElements[ 0 ]; - context.core.image.lastFocusableElement = - focusableElements[ - focusableElements.length - 1 - ]; - - // Move focus to the dialog when opening it. - ref.focus(); - } - }, - setButtonStyles: ( { context, ref } ) => { - const { - naturalWidth, - naturalHeight, - offsetWidth, - offsetHeight, - } = ref; - - // If the image isn't loaded yet, we can't - // calculate where the button should be. - if ( naturalWidth === 0 || naturalHeight === 0 ) { - return; - } - - const figure = ref.parentElement; - const figureWidth = ref.parentElement.clientWidth; - - // We need special handling for the height because - // a caption will cause the figure to be taller than - // the image, which means we need to account for that - // when calculating the placement of the button in the - // top right corner of the image. - let figureHeight = ref.parentElement.clientHeight; - const caption = figure.querySelector( 'figcaption' ); - if ( caption ) { - const captionComputedStyle = - window.getComputedStyle( caption ); - if ( - ! [ 'absolute', 'fixed' ].includes( - captionComputedStyle.position - ) - ) { - figureHeight = - figureHeight - - caption.offsetHeight - - parseFloat( - captionComputedStyle.marginTop - ) - - parseFloat( - captionComputedStyle.marginBottom - ); - } - } - - const buttonOffsetTop = figureHeight - offsetHeight; - const buttonOffsetRight = figureWidth - offsetWidth; - - // In the case of an image with object-fit: contain, the - // size of the element can be larger than the image itself, - // so we need to calculate where to place the button. - if ( context.core.image.scaleAttr === 'contain' ) { - // Natural ratio of the image. - const naturalRatio = naturalWidth / naturalHeight; - // Offset ratio of the image. - const offsetRatio = offsetWidth / offsetHeight; - - if ( naturalRatio >= offsetRatio ) { - // If it reaches the width first, keep - // the width and compute the height. - const referenceHeight = - offsetWidth / naturalRatio; - context.core.image.imageButtonTop = - ( offsetHeight - referenceHeight ) / 2 + - buttonOffsetTop + - 16; - context.core.image.imageButtonRight = - buttonOffsetRight + 16; - } else { - // If it reaches the height first, keep - // the height and compute the width. - const referenceWidth = - offsetHeight * naturalRatio; - context.core.image.imageButtonTop = - buttonOffsetTop + 16; - context.core.image.imageButtonRight = - ( offsetWidth - referenceWidth ) / 2 + - buttonOffsetRight + - 16; - } - } else { - context.core.image.imageButtonTop = - buttonOffsetTop + 16; - context.core.image.imageButtonRight = - buttonOffsetRight + 16; - } - }, - setStylesOnResize: ( { state, context, ref } ) => { - if ( - context.core.image.lightboxEnabled && - ( state.core.image.windowWidth || - state.core.image.windowHeight ) - ) { - setStyles( context, ref ); - } - }, - }, - }, + get lightboxObjectFit() { + const ctx = getContext(); + if ( ctx.initialized ) { + return 'cover'; + } + }, + get enlargedImgSrc() { + const ctx = getContext(); + return ctx.initialized + ? ctx.imageUploadedSrc + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; }, }, - { - afterLoad: ( { state } ) => { - window.addEventListener( - 'resize', - debounce( () => { - state.core.image.windowWidth = window.innerWidth; - state.core.image.windowHeight = window.innerHeight; - } ) - ); + actions: { + showLightbox( event ) { + const ctx = getContext(); + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + if ( ! ctx.imageLoaded ) { + return; + } + ctx.initialized = true; + ctx.lastFocusedElement = window.document.activeElement; + ctx.scrollDelta = 0; + ctx.pointerType = event.pointerType; + + ctx.lightboxEnabled = true; + setStyles( ctx, ctx.imageRef ); + + ctx.scrollTopReset = + window.pageYOffset || document.documentElement.scrollTop; + + // In most cases, this value will be 0, but this is included + // in case a user has created a page with horizontal scrolling. + ctx.scrollLeftReset = + window.pageXOffset || document.documentElement.scrollLeft; + + // We define and bind the scroll callback here so + // that we can pass the context and as an argument. + // We may be able to change this in the future if we + // define the scroll callback in the store instead, but + // this approach seems to tbe clearest for now. + scrollCallback = handleScroll.bind( null, ctx ); + + // We need to add a scroll event listener to the window + // here because we are unable to otherwise access it via + // the Interactivity API directives. If we add a native way + // to access the window, we can remove this. + window.addEventListener( 'scroll', scrollCallback, false ); }, - } + hideLightbox() { + const ctx = getContext(); + ctx.hideAnimationEnabled = true; + if ( ctx.lightboxEnabled ) { + // We want to wait until the close animation is completed + // before allowing a user to scroll again. The duration of this + // animation is defined in the styles.scss and depends on if the + // animation is 'zoom' or 'fade', but in any case we should wait + // a few milliseconds longer than the duration, otherwise a user + // may scroll too soon and cause the animation to look sloppy. + setTimeout( function () { + window.removeEventListener( 'scroll', scrollCallback ); + // If we don't delay before changing the focus, + // the focus ring will appear on Firefox before + // the image has finished animating, which looks broken. + ctx.lightboxTriggerRef.focus( { + preventScroll: true, + } ); + }, 450 ); + + ctx.lightboxEnabled = false; + } + }, + handleKeydown( event ) { + const ctx = getContext(); + if ( ctx.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + ctx.firstFocusableElement + ) { + event.preventDefault(); + ctx.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + ctx.lastFocusableElement + ) { + event.preventDefault(); + ctx.firstFocusableElement.focus(); + } + } + + if ( event.key === 'Escape' || event.keyCode === 27 ) { + actions.hideLightbox( event ); + } + } + }, + // This is fired just by lazily loaded + // images on the page, not all images. + handleLoad() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.imageLoaded = true; + ctx.imageCurrentSrc = ref.currentSrc; + callbacks.setButtonStyles(); + }, + handleTouchStart() { + isTouching = true; + }, + handleTouchMove( event ) { + const ctx = getContext(); + // On mobile devices, we want to prevent triggering the + // scroll event because otherwise the page jumps around as + // we reset the scroll position. This also means that closing + // the lightbox requires that a user perform a simple tap. This + // may be changed in the future if we find a better alternative + // to override or reset the scroll position during swipe actions. + if ( ctx.lightboxEnabled ) { + event.preventDefault(); + } + }, + handleTouchEnd() { + // We need to wait a few milliseconds before resetting + // to ensure that pinch to zoom works consistently + // on mobile devices when the lightbox is open. + lastTouchTime = Date.now(); + isTouching = false; + }, + }, + callbacks: { + initOriginImage() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.imageRef = ref; + ctx.lightboxTriggerRef = + ref.parentElement.querySelector( '.lightbox-trigger' ); + if ( ref.complete ) { + ctx.imageLoaded = true; + ctx.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox() { + const ctx = getContext(); + const { ref } = getElement(); + if ( ctx.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + + // Move focus to the dialog when opening it. + ref.focus(); + } + }, + setButtonStyles() { + const { ref } = getElement(); + const { naturalWidth, naturalHeight, offsetWidth, offsetHeight } = + ref; + + // If the image isn't loaded yet, we can't + // calculate where the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } + + const figure = ref.parentElement; + const figureWidth = ref.parentElement.clientWidth; + + // We need special handling for the height because + // a caption will cause the figure to be taller than + // the image, which means we need to account for that + // when calculating the placement of the button in the + // top right corner of the image. + let figureHeight = ref.parentElement.clientHeight; + const caption = figure.querySelector( 'figcaption' ); + if ( caption ) { + const captionComputedStyle = window.getComputedStyle( caption ); + if ( + ! [ 'absolute', 'fixed' ].includes( + captionComputedStyle.position + ) + ) { + figureHeight = + figureHeight - + caption.offsetHeight - + parseFloat( captionComputedStyle.marginTop ) - + parseFloat( captionComputedStyle.marginBottom ); + } + } + + const buttonOffsetTop = figureHeight - offsetHeight; + const buttonOffsetRight = figureWidth - offsetWidth; + + const ctx = getContext(); + + // In the case of an image with object-fit: contain, the + // size of the element can be larger than the image itself, + // so we need to calculate where to place the button. + if ( ctx.scaleAttr === 'contain' ) { + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio >= offsetRatio ) { + // If it reaches the width first, keep + // the width and compute the height. + const referenceHeight = offsetWidth / naturalRatio; + ctx.imageButtonTop = + ( offsetHeight - referenceHeight ) / 2 + + buttonOffsetTop + + 16; + ctx.imageButtonRight = buttonOffsetRight + 16; + } else { + // If it reaches the height first, keep + // the height and compute the width. + const referenceWidth = offsetHeight * naturalRatio; + ctx.imageButtonTop = buttonOffsetTop + 16; + ctx.imageButtonRight = + ( offsetWidth - referenceWidth ) / 2 + + buttonOffsetRight + + 16; + } + } else { + ctx.imageButtonTop = buttonOffsetTop + 16; + ctx.imageButtonRight = buttonOffsetRight + 16; + } + }, + setStylesOnResize() { + const ctx = getContext(); + const { ref } = getElement(); + if ( + ctx.lightboxEnabled && + ( state.windowWidth || state.windowHeight ) + ) { + setStyles( ctx, ref ); + } + }, + }, +} ); + +window.addEventListener( + 'resize', + debounce( () => { + state.windowWidth = window.innerWidth; + state.windowHeight = window.innerHeight; + } ) ); -/* +/** * Computes styles for the lightbox and adds them to the document. * * @function - * @param {Object} context - An Interactivity API context - * @param {Object} event - A triggering event + * @param {Object} ctx - Context for the `core/image` namespace. + * @param {Object} ref - The element reference. */ -function setStyles( context, ref ) { +function setStyles( ctx, ref ) { // The reference img element lies adjacent // to the event target button in the DOM. let { @@ -423,7 +371,7 @@ function setStyles( context, ref ) { // If it has object-fit: contain, recalculate the original sizes // and the screen position without the blank spaces. - if ( context.core.image.scaleAttr === 'contain' ) { + if ( ctx.scaleAttr === 'contain' ) { if ( naturalRatio > originalRatio ) { const heightWithoutSpace = originalWidth / naturalRatio; // Recalculate screen position without the top space. @@ -443,14 +391,10 @@ function setStyles( context, ref ) { // the image's dimensions in the lightbox are the same // as those of the image in the content. let imgMaxWidth = parseFloat( - context.core.image.targetWidth !== 'none' - ? context.core.image.targetWidth - : naturalWidth + ctx.targetWidth !== 'none' ? ctx.targetWidth : naturalWidth ); let imgMaxHeight = parseFloat( - context.core.image.targetHeight !== 'none' - ? context.core.image.targetHeight - : naturalHeight + ctx.targetHeight !== 'none' ? ctx.targetHeight : naturalHeight ); // Ratio of the biggest image stored in the database. @@ -575,12 +519,12 @@ function setStyles( context, ref ) { `; } -/* +/** * Debounces a function call. * * @function * @param {Function} func - A function to be called - * @param {number} wait - The time to wait before calling the function + * @param {number} wait - The time to wait before calling the function */ function debounce( func, wait = 50 ) { let timeout; diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 5e518d5c374148..6550d896656b1b 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -101,11 +101,11 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut ) ) ) { // Add directives to the parent `
  • `. - $tags->set_attribute( 'data-wp-interactive', true ); - $tags->set_attribute( 'data-wp-context', '{ "core": { "navigation": { "submenuOpenedBy": {}, "type": "submenu" } } }' ); - $tags->set_attribute( 'data-wp-effect', 'effects.core.navigation.initMenu' ); - $tags->set_attribute( 'data-wp-on--focusout', 'actions.core.navigation.handleMenuFocusout' ); - $tags->set_attribute( 'data-wp-on--keydown', 'actions.core.navigation.handleMenuKeydown' ); + $tags->set_attribute( 'data-wp-interactive', '{ "namespace": "core/navigation" }' ); + $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); + $tags->set_attribute( 'data-wp-watch', 'callbacks.initMenu' ); + $tags->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); + $tags->set_attribute( 'data-wp-on--keydown', 'actions.handleMenuKeydown' ); // This is a fix for Safari. Without it, Safari doesn't change the active // element when the user clicks on a button. It can be removed once we add @@ -114,8 +114,8 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut $tags->set_attribute( 'tabindex', '-1' ); if ( ! isset( $block_attributes['openSubmenusOnClick'] ) || false === $block_attributes['openSubmenusOnClick'] ) { - $tags->set_attribute( 'data-wp-on--mouseenter', 'actions.core.navigation.openMenuOnHover' ); - $tags->set_attribute( 'data-wp-on--mouseleave', 'actions.core.navigation.closeMenuOnHover' ); + $tags->set_attribute( 'data-wp-on--mouseenter', 'actions.openMenuOnHover' ); + $tags->set_attribute( 'data-wp-on--mouseleave', 'actions.closeMenuOnHover' ); } // Add directives to the toggle submenu button. @@ -125,8 +125,8 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut 'class_name' => 'wp-block-navigation-submenu__toggle', ) ) ) { - $tags->set_attribute( 'data-wp-on--click', 'actions.core.navigation.toggleMenuOnClick' ); - $tags->set_attribute( 'data-wp-bind--aria-expanded', 'selectors.core.navigation.isMenuOpen' ); + $tags->set_attribute( 'data-wp-on--click', 'actions.toggleMenuOnClick' ); + $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isMenuOpen' ); // The `aria-expanded` attribute for SSR is already added in the submenu block. } // Add directives to the submenu. @@ -136,7 +136,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut 'class_name' => 'wp-block-navigation__submenu-container', ) ) ) { - $tags->set_attribute( 'data-wp-on--focus', 'actions.core.navigation.openMenuOnFocus' ); + $tags->set_attribute( 'data-wp-on--focus', 'actions.openMenuOnFocus' ); } // Iterate through subitems if exist. diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index bad36f6240134f..ba8e6d1a6683a4 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -18,205 +18,172 @@ const focusableSelectors = [ // capture the clicks, instead of relying on the focusout event. document.addEventListener( 'click', () => {} ); -const openMenu = ( store, menuOpenedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; - if ( context.core.navigation.type === 'overlay' ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); - } -}; - -const closeMenu = ( store, menuClosedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! selectors.core.navigation.isMenuOpen( store ) ) { - if ( - context.core.navigation.modal?.contains( - window.document.activeElement - ) - ) { - context.core.navigation.previousFocus?.focus(); - } - context.core.navigation.modal = null; - context.core.navigation.previousFocus = null; - if ( context.core.navigation.type === 'overlay' ) { - document.documentElement.classList.remove( 'has-modal-open' ); - } - } -}; - -wpStore( { - effects: { - core: { - navigation: { - initMenu: ( store ) => { - const { context, selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.navigation.modal = ref; - context.core.navigation.firstFocusableElement = - focusableElements[ 0 ]; - context.core.navigation.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement: ( store ) => { - const { selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, +const { state, actions } = store( 'core/navigation', { + state: { + get roleAttribute() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'dialog' : null; }, - }, - selectors: { - core: { - navigation: { - roleAttribute: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'dialog' - : null; - }, - ariaModal: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'true' - : null; - }, - ariaLabel: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? context.core.navigation.ariaLabel - : null; - }, - isMenuOpen: ( { context } ) => - // The menu is opened if either `click`, `hover` or `focus` is true. - Object.values( - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ] - ).filter( Boolean ).length > 0, - menuOpenedBy: ( { context } ) => - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ], - }, + get ariaModal() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'true' : null; + }, + get ariaLabel() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? ctx.ariaLabel + : null; + }, + get isMenuOpen() { + // The menu is opened if either `click`, `hover` or `focus` is true. + return ( + Object.values( state.menuOpenedBy ).filter( Boolean ).length > 0 + ); + }, + get menuOpenedBy() { + const ctx = getContext(); + return ctx.type === 'overlay' + ? ctx.overlayOpenedBy + : ctx.submenuOpenedBy; }, }, actions: { - core: { - navigation: { - openMenuOnHover( store ) { - const { navigation } = store.context.core; - if ( - navigation.type === 'submenu' && - // Only open on hover if the overlay is closed. - Object.values( - navigation.overlayOpenedBy || {} - ).filter( Boolean ).length === 0 - ) - openMenu( store, 'hover' ); - }, - closeMenuOnHover( store ) { - closeMenu( store, 'hover' ); - }, - openMenuOnClick( store ) { - const { context, ref } = store; - context.core.navigation.previousFocus = ref; - openMenu( store, 'click' ); - }, - closeMenuOnClick( store ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - }, - openMenuOnFocus( store ) { - openMenu( store, 'focus' ); - }, - toggleMenuOnClick: ( store ) => { - const { selectors, context, ref } = store; - // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 - if ( window.document.activeElement !== ref ) ref.focus(); - const menuOpenedBy = - selectors.core.navigation.menuOpenedBy( store ); - if ( menuOpenedBy.click || menuOpenedBy.focus ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - } else { - context.core.navigation.previousFocus = ref; - openMenu( store, 'click' ); - } - }, - handleMenuKeydown: ( store ) => { - const { context, selectors, event } = store; - if ( - selectors.core.navigation.menuOpenedBy( store ).click - ) { - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - return; - } - - // Trap focus if it is an overlay (main menu). - if ( - context.core.navigation.type === 'overlay' && - event.key === 'Tab' - ) { - // If shift + tab it change the direction. - if ( - event.shiftKey && - window.document.activeElement === - context.core.navigation - .firstFocusableElement - ) { - event.preventDefault(); - context.core.navigation.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.navigation.lastFocusableElement - ) { - event.preventDefault(); - context.core.navigation.firstFocusableElement.focus(); - } - } - } - }, - handleMenuFocusout: ( store ) => { - const { context, event } = store; - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, - // `window.document.activeElement` doesn't change. + openMenuOnHover() { + const { type, overlayOpenedBy } = getContext(); + if ( + type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( overlayOpenedBy || {} ).filter( Boolean ) + .length === 0 + ) + actions.openMenu( 'hover' ); + }, + closeMenuOnHover() { + actions.closeMenu( 'hover' ); + }, + openMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.previousFocus = ref; + actions.openMenu( 'click' ); + }, + closeMenuOnClick() { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + }, + openMenuOnFocus() { + actions.openMenu( 'focus' ); + }, + toggleMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); + // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 + if ( window.document.activeElement !== ref ) ref.focus(); + const { menuOpenedBy } = state; + if ( menuOpenedBy.click || menuOpenedBy.focus ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } else { + ctx.previousFocus = ref; + actions.openMenu( 'click' ); + } + }, + handleMenuKeydown( event ) { + const { type, firstFocusableElement, lastFocusableElement } = + getContext(); + if ( state.menuOpenedBy.click ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + return; + } - // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. + // Trap focus if it is an overlay (main menu). + if ( type === 'overlay' && event.key === 'Tab' ) { + // If shift + tab it change the direction. if ( - event.relatedTarget === null || - ( ! context.core.navigation.modal?.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement ) + event.shiftKey && + window.document.activeElement === firstFocusableElement ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); + event.preventDefault(); + lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === lastFocusableElement + ) { + event.preventDefault(); + firstFocusableElement.focus(); } - }, - }, + } + } + }, + handleMenuFocusout( event ) { + const { modal } = getContext(); + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. + + // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. + if ( + event.relatedTarget === null || + ( ! modal?.contains( event.relatedTarget ) && + event.target !== window.document.activeElement ) + ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } + }, + + openMenu( menuOpenedOn = 'click' ) { + const { type } = getContext(); + state.menuOpenedBy[ menuOpenedOn ] = true; + if ( type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } + }, + + closeMenu( menuClosedOn = 'click' ) { + const ctx = getContext(); + state.menuOpenedBy[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! state.isMenuOpen ) { + if ( ctx.modal?.contains( window.document.activeElement ) ) { + ctx.previousFocus?.focus(); + } + ctx.modal = null; + ctx.previousFocus = null; + if ( ctx.type === 'overlay' ) { + document.documentElement.classList.remove( + 'has-modal-open' + ); + } + } + }, + }, + callbacks: { + initMenu() { + const ctx = getContext(); + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.modal = ref; + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement() { + const { ref } = getElement(); + if ( state.isMenuOpen ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } }, }, } ); diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 768fde56ff06f3..ca134f62192f9e 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -72,9 +72,9 @@ function render_block_core_query_pagination_next( $attributes, $content, $block ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-next' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 98098533adac7d..2f9370751f6d25 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -98,7 +98,7 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo 'class_name' => 'page-numbers', ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); } $content = $p->get_updated_html(); } diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index fc1fee08e82148..b49130a44d8ddf 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -60,9 +60,9 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-previous' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index b6a5733632ff44..6daf2411233bdb 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -21,19 +21,15 @@ function render_block_core_query( $attributes, $content, $block ) { $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { // Add the necessary directives. - $p->set_attribute( 'data-wp-interactive', true ); + $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/query"}' ); $p->set_attribute( 'data-wp-navigation-id', 'query-' . $attributes['queryId'] ); // Use context to send translated strings. $p->set_attribute( 'data-wp-context', wp_json_encode( array( - 'core' => array( - 'query' => array( - 'loadingText' => __( 'Loading page, please wait.' ), - 'loadedText' => __( 'Page Loaded.' ), - ), - ), + 'loadingText' => __( 'Loading page, please wait.' ), + 'loadedText' => __( 'Page Loaded.' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ) @@ -54,12 +50,12 @@ function render_block_core_query( $attributes, $content, $block ) { '
    ', $last_tag_position, 0 diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 1dac448952b11e..ccf70810047673 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,7 +1,13 @@ /** * WordPress dependencies */ -import { store, navigate, prefetch } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + navigate, + prefetch, +} from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -18,83 +24,70 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -store( { - selectors: { - core: { - query: { - startAnimation: ( { context } ) => - context.core.query.animation === 'start', - finishAnimation: ( { context } ) => - context.core.query.animation === 'finish', - }, +store( 'core/query', { + state: { + get startAnimation() { + return getContext().animation === 'start'; + }, + get finishAnimation() { + return getContext().animation === 'finish'; }, }, actions: { - core: { - query: { - navigate: async ( { event, ref, context } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; + *navigate( event ) { + const ctx = getContext(); + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; - if ( - isValidLink( ref ) && - isValidEvent( event ) && - ! isDisabled - ) { - event.preventDefault(); + if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { + event.preventDefault(); - const id = ref.closest( '[data-wp-navigation-id]' ) - .dataset.wpNavigationId; + const id = ref.closest( '[data-wp-navigation-id]' ).dataset + .wpNavigationId; - // Don't announce the navigation immediately, wait 400 ms. - const timeout = setTimeout( () => { - context.core.query.message = - context.core.query.loadingText; - context.core.query.animation = 'start'; - }, 400 ); + // Don't announce the navigation immediately, wait 400 ms. + const timeout = setTimeout( () => { + ctx.message = ctx.loadingText; + ctx.animation = 'start'; + }, 400 ); - await navigate( ref.href ); + yield navigate( ref.href ); - // Dismiss loading message if it hasn't been added yet. - clearTimeout( timeout ); + // Dismiss loading message if it hasn't been added yet. + clearTimeout( timeout ); - // Announce that the page has been loaded. If the message is the - // same, we use a no-break space similar to the @wordpress/a11y - // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 - context.core.query.message = - context.core.query.loadedText + - ( context.core.query.message === - context.core.query.loadedText - ? '\u00A0' - : '' ); + // Announce that the page has been loaded. If the message is the + // same, we use a no-break space similar to the @wordpress/a11y + // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 + ctx.message = + ctx.loadedText + + ( ctx.message === ctx.loadedText ? '\u00A0' : '' ); - context.core.query.animation = 'finish'; - context.core.query.url = ref.href; + ctx.animation = 'finish'; + ctx.url = ref.href; - // Focus the first anchor of the Query block. - const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; - document.querySelector( firstAnchor )?.focus(); - } - }, - prefetch: async ( { ref } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; - if ( isValidLink( ref ) && ! isDisabled ) { - await prefetch( ref.href ); - } - }, - }, + // Focus the first anchor of the Query block. + const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; + document.querySelector( firstAnchor )?.focus(); + } + }, + *prefetch() { + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; + if ( isValidLink( ref ) && ! isDisabled ) { + yield prefetch( ref.href ); + } }, }, - effects: { - core: { - query: { - prefetch: async ( { ref, context } ) => { - if ( context.core.query.url && isValidLink( ref ) ) { - await prefetch( ref.href ); - } - }, - }, + callbacks: { + *prefetch() { + const { url } = getContext(); + const { ref } = getElement(); + if ( url && isValidLink( ref ) ) { + yield prefetch( ref.href ); + } }, }, } ); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index f00ecfe6abe1cc..ec7e763ecb1f60 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -80,8 +80,8 @@ function render_block_core_search( $attributes, $content, $block ) { $is_expandable_searchfield = 'button-only' === $button_position && 'expand-searchfield' === $button_behavior; if ( $is_expandable_searchfield ) { - $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.core.search.isSearchInputVisible' ); - $input->set_attribute( 'data-wp-bind--tabindex', 'selectors.core.search.tabindex' ); + $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); + $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); @@ -145,11 +145,11 @@ function render_block_core_search( $attributes, $content, $block ) { if ( $button->next_tag() ) { $button->add_class( implode( ' ', $button_classes ) ); if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { - $button->set_attribute( 'data-wp-bind--aria-label', 'selectors.core.search.ariaLabel' ); - $button->set_attribute( 'data-wp-bind--aria-controls', 'selectors.core.search.ariaControls' ); - $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.core.search.isSearchInputVisible' ); - $button->set_attribute( 'data-wp-bind--type', 'selectors.core.search.type' ); - $button->set_attribute( 'data-wp-on--click', 'actions.core.search.openSearchInput' ); + $button->set_attribute( 'data-wp-bind--aria-label', 'state.ariaLabel' ); + $button->set_attribute( 'data-wp-bind--aria-controls', 'state.ariaControls' ); + $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.isSearchInputVisible' ); + $button->set_attribute( 'data-wp-bind--type', 'state.type' ); + $button->set_attribute( 'data-wp-on--click', 'actions.openSearchInput' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); @@ -176,11 +176,11 @@ function render_block_core_search( $attributes, $content, $block ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); $form_directives = ' - data-wp-interactive - data-wp-context=\'{ "core": { "search": { "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" } } }\' - data-wp-class--wp-block-search__searchfield-hidden="!context.core.search.isSearchInputVisible" - data-wp-on--keydown="actions.core.search.handleSearchKeydown" - data-wp-on--focusout="actions.core.search.handleSearchFocusout" + data-wp-interactive=\'{ "namespace": "core/search" }\' + data-wp-context=\'{ "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" }\' + data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" + data-wp-on--keydown="actions.handleSearchKeydown" + data-wp-on--focusout="actions.handleSearchFocusout" '; } diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index d99dfc5696ccbb..b633bf971f363a 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,73 +1,68 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; -wpStore( { - selectors: { - core: { - search: { - ariaLabel: ( { context } ) => { - const { ariaLabelCollapsed, ariaLabelExpanded } = - context.core.search; - return context.core.search.isSearchInputVisible - ? ariaLabelExpanded - : ariaLabelCollapsed; - }, - ariaControls: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? null - : context.core.search.inputId; - }, - type: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? 'submit' - : 'button'; - }, - tabindex: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? '0' - : '-1'; - }, - }, +const { actions } = store( 'core/search', { + state: { + get ariaLabel() { + const { + isSearchInputVisible, + ariaLabelCollapsed, + ariaLabelExpanded, + } = getContext(); + return isSearchInputVisible + ? ariaLabelExpanded + : ariaLabelCollapsed; + }, + get ariaControls() { + const { isSearchInputVisible, inputId } = getContext(); + return isSearchInputVisible ? null : inputId; + }, + get type() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? 'submit' : 'button'; + }, + get tabindex() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? '0' : '-1'; }, }, actions: { - core: { - search: { - openSearchInput: ( { context, event, ref } ) => { - if ( ! context.core.search.isSearchInputVisible ) { - event.preventDefault(); - context.core.search.isSearchInputVisible = true; - ref.parentElement.querySelector( 'input' ).focus(); - } - }, - closeSearchInput: ( { context } ) => { - context.core.search.isSearchInputVisible = false; - }, - handleSearchKeydown: ( store ) => { - const { actions, event, ref } = store; - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - actions.core.search.closeSearchInput( store ); - ref.querySelector( 'button' ).focus(); - } - }, - handleSearchFocusout: ( store ) => { - const { actions, event, ref } = store; - // If focus is outside search form, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outside the document, - // `window.document.activeElement` doesn't change. - if ( - ! ref.contains( event.relatedTarget ) && - event.target !== window.document.activeElement - ) { - actions.core.search.closeSearchInput( store ); - } - }, - }, + openSearchInput( event ) { + const ctx = getContext(); + const { ref } = getElement(); + if ( ! ctx.isSearchInputVisible ) { + event.preventDefault(); + ctx.isSearchInputVisible = true; + ref.parentElement.querySelector( 'input' ).focus(); + } + }, + closeSearchInput() { + const ctx = getContext(); + ctx.isSearchInputVisible = false; + }, + handleSearchKeydown( event ) { + const { ref } = getElement(); + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeSearchInput(); + ref.querySelector( 'button' ).focus(); + } + }, + handleSearchFocusout( event ) { + const { ref } = getElement(); + // If focus is outside search form, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outside the document, + // `window.document.activeElement` doesn't change. + if ( + ! ref.contains( event.relatedTarget ) && + event.target !== window.document.activeElement + ) { + actions.closeSearchInput(); + } }, }, } ); diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index ddd88be5189a46..a9a30e9804e1f3 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../html-entities" }, { "path": "../i18n" }, { "path": "../icons" }, + { "path": "../interactivity" }, { "path": "../notices" }, { "path": "../keycodes" }, { "path": "../primitives" }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php index a94eb20bfa6d54..f313262b7c0587 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php @@ -6,7 +6,7 @@ */ ?> -
    +
    { - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + const { state, foo } = store( 'directive-bind', { state: { url: '/some-url', checked: true, @@ -12,13 +12,14 @@ bar: 1, }, actions: { - toggle: ( { state, foo } ) => { + toggle: () => { state.url = '/some-other-url'; state.checked = ! state.checked; state.show = ! state.show; state.width += foo.bar; }, - toggleValue: ( { context } ) => { + toggleValue: () => { + const context = getContext(); const previousValue = ( 'previousValue' in context ) ? context.previousValue // Any string works here; we just want to toggle the value diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php index 5e24b7d7a3b9b5..efca342b1babcc 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php @@ -7,7 +7,7 @@ ?>
    diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js index f3cbc521f4355b..764855643d6f6a 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js @@ -1,9 +1,10 @@ ( ( { wp } ) => { - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + store( 'directive-body', { actions: { - toggleText: ( { context } ) => { + toggleText: () => { + const context = getContext(); context.text = context.text === 'text-1' ? 'text-2' : 'text-1'; }, }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php index b229418de2f67d..92e0c7c78a082f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -6,7 +6,7 @@ */ ?> -
    +
    `; - store( { - derived: { - renderContext: ( { context } ) => { - return JSON.stringify( context, undefined, 2 ); - }, - }, + const { actions } = store( 'directive-context-navigate', { actions: { - updateContext: ( { context, event } ) => { - const { name, value } = event.target; - const [ key, ...path ] = name.split( '.' ).reverse(); - const obj = path.reduceRight( ( o, k ) => o[ k ], context ); - obj[ key ] = value; + toggleText() { + const ctx = getContext(); + ctx.text = "changed dynamically"; }, - toggleContextText: ( { context } ) => { - context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + addNewText() { + const ctx = getContext(); + ctx.newText = 'some new text'; }, - toggleText: ( { context } ) => { - context.text = "changed dynamically"; - }, - addNewText: ( { context } ) => { - context.newText = 'some new text'; - }, - navigate: () => { - navigate( window.location, { + navigate() { + return navigate( window.location, { force: true, html, } ); }, - asyncNavigate: async ({ context }) => { - await navigate( window.location, { - force: true, - html, - } ); - context.newText = 'changed from async action'; + * asyncNavigate() { + yield actions.navigate(); + const ctx = getContext(); + ctx.newText = 'changed from async action'; } }, } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json deleted file mode 100644 index b9cb2f782b2e6f..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "apiVersion": 2, - "name": "test/directive-effect", - "title": "E2E Interactivity tests - directive effect", - "category": "text", - "icon": "heart", - "description": "", - "supports": { - "interactivity": true - }, - "textdomain": "e2e-interactivity", - "viewScript": "directive-effect-view", - "render": "file:./render.php" -} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php index 76d5b776a68bb3..1d6774335442af 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php @@ -6,14 +6,14 @@ */ ?> -
    +
    -

    false

    -

    0

    +

    false

    +

    0

    -

    false,false

    -

    0,0

    +

    false,false

    +

    0,0

    toggle -

    +

    true

    diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js index 7fa79a5091e908..d9474fbff8caa3 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js @@ -1,20 +1,17 @@ ( ( { wp } ) => { - const { store, directive, useContext } = wp.interactivity; + const { store, directive, getContext } = wp.interactivity; // Mock `data-wp-show` directive to test when things are removed from the // DOM. Replace with `data-wp-show` when it's ready. directive( 'show-mock', ( { - directives: { - 'show-mock': { default: showMock }, - }, + directives: { 'show-mock': showMock }, element, evaluate, - context, } ) => { - const contextValue = useContext( context ); - if ( ! evaluate( showMock, { context: contextValue } ) ) { + const entry = showMock.find( ( { suffix} ) => suffix === 'default'); + if ( ! evaluate( entry ) ) { return null; } return element; @@ -22,41 +19,49 @@ ); - store( { - selector: { - isReady: ({ context: { isReady } }) => { + store( 'directive-init', { + state: { + get isReady() { + const { isReady } = getContext(); return isReady - .map(v => v ? 'true': 'false') - .join(','); + .map(v => v ? 'true': 'false') + .join(','); }, - calls: ({ context: { calls } }) => { + get calls() { + const { calls } = getContext(); return calls.join(','); }, - isMounted: ({ context }) => { - return context.isMounted ? 'true' : 'false'; + get isMounted() { + const { isMounted } = getContext(); + return isMounted ? 'true' : 'false'; }, }, actions: { - initOne: ( { context: { isReady, calls } } ) => { + initOne() { + const { isReady, calls } = getContext(); isReady[0] = true; // Subscribe to changes in that prop. calls[0]++; }, - initTwo: ( { context: { isReady, calls } } ) => { + initTwo() { + const { isReady, calls } = getContext(); isReady[1] = true; calls[1]++; }, - initMount: ( { context } ) => { - context.isMounted = true; + initMount() { + const ctx = getContext(); + ctx.isMounted = true; return () => { - context.isMounted = false; + ctx.isMounted = false; } }, - reset: ( { context: { isReady } } ) => { + reset() { + const { isReady } = getContext(); isReady.fill(false); }, - toggle: ( { context } ) => { - context.isVisible = ! context.isVisible; + toggle() { + const ctx = getContext(); + ctx.isVisible = ! ctx.isVisible; }, }, } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php index 07c6e4e3de161d..c163a0523420fd 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php @@ -7,7 +7,10 @@ ?> -
    +
    • 2
    • 3
    • diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js index a155dec99e0aa9..28a862eaef4ffa 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js @@ -2,7 +2,10 @@ const { store, navigate } = wp.interactivity; const html = ` -
      +
      • 1
      • 2
      • @@ -10,9 +13,9 @@
      `; - store( { + store( 'directive-key', { actions: { - navigate: () => { + navigate() { navigate( window.location, { force: true, html, diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php index 9d96c7768a4894..3df028b9907d19 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-on/render.php @@ -6,7 +6,7 @@ */ ?> -
      +

      0

      diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js index 64a84269c356e3..58f6180cade69f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -4,17 +4,15 @@ */ const { store } = wp.interactivity; - store( { - selectors: { - active: ( { state } ) => { - return state.active; - }, - }, + const { state } = store( 'negation-operator', { state: { active: false, + get isActive() { + return state.active; + }, }, actions: { - toggle: ( { state } ) => { + toggle() { state.active = ! state.active; }, }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php index 90246623ed997b..621d240064f0ab 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php @@ -9,23 +9,26 @@ ?> -
      +

      NaN undefined link $i
      link $i with hash diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js index 422750eec366ef..9b17bbcff9f2ed 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js @@ -4,38 +4,34 @@ */ const { store, navigate } = wp.interactivity; - store( { + const { state } = store( 'router', { state: { - router: { - status: 'idle', - navigations: 0, - timeout: 10000, - } + status: 'idle', + navigations: 0, + timeout: 10000, }, actions: { - router: { - navigate: async ( { state, event: e } ) => { - e.preventDefault(); + * navigate( e ) { + e.preventDefault(); - state.router.navigations += 1; - state.router.status = 'busy'; + state.navigations += 1; + state.status = 'busy'; - const force = e.target.dataset.forceNavigation === 'true'; - const { timeout } = state.router; + const force = e.target.dataset.forceNavigation === 'true'; + const { timeout } = state; - await navigate( e.target.href, { force, timeout } ); + yield navigate( e.target.href, { force, timeout } ); - state.router.navigations -= 1; + state.navigations -= 1; - if ( state.router.navigations === 0) { - state.router.status = 'idle'; - } - }, - toggleTimeout: ( { state }) => { - state.router.timeout = - state.router.timeout === 10000 ? 0 : 10000; + if ( state.navigations === 0) { + state.status = 'idle'; } }, + toggleTimeout() { + state.timeout = + state.timeout === 10000 ? 0 : 10000; + } }, } ); } )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php index db6e75709f9792..dc38107a12a4c1 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -10,7 +10,10 @@

      Region 1

      -
      +

      Region 2

      -
      +

      Nested region

      -
      +

      content from page

      diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js index 296c77d3ee7b38..213c61a5a9174d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/view.js @@ -2,9 +2,9 @@ /** * WordPress dependencies */ - const { store, navigate } = wp.interactivity; + const { store, navigate, getContext } = wp.interactivity; - store( { + const { state } = store( 'router-regions', { state: { region1: { text: 'hydrated' @@ -18,21 +18,25 @@ }, actions: { router: { - navigate: async ( { event: e } ) => { + * navigate( e ) { e.preventDefault(); - await navigate( e.target.href ); + yield navigate( e.target.href ); + }, + back() { + history.back(); }, - back: () => history.back(), }, counter: { - increment: ( { state, context } ) => { - if ( context.counter ) { + increment() { + const context = getContext(); + if ( context?.counter ) { context.counter.value += 1; } else { state.counter.value += 1; } }, - init: ( { context } ) => { + init() { + const context = getContext(); if ( context.counter ) { context.counter.value = context.counter.initialValue; } diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php deleted file mode 100644 index 950ba923428bf1..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/render.php +++ /dev/null @@ -1,41 +0,0 @@ - -
      -

      Store statuses

      -

      waiting

      -

      waiting

      -

      waiting

      -

      waiting

      - -

      afterLoad executions

      -

      All stores ready: - - >waiting -

      -

      vDOM ready: - - >waiting -

      -

      afterLoad exec times: - - >0 -

      -

      sharedAfterLoad exec times: - - >0 -

      -
      diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js deleted file mode 100644 index 361a56dc622830..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/store-afterload/view.js +++ /dev/null @@ -1,60 +0,0 @@ -( ( { wp } ) => { - /** - * WordPress dependencies - */ - const { store } = wp.interactivity; - - const afterLoad = ({ state }) => { - // Check the state is correctly initialized. - const { status1, status2, status3, status4 } = state; - state.allStoresReady = - [ status1, status2, status3, status4 ] - .every( ( t ) => t === 'ready' ) - .toString(); - - // Check the HTML has been processed as well. - const selector = '[data-store-status]'; - state.vdomReady = - document.querySelector( selector ) && - Array.from( - document.querySelectorAll( selector ) - ).every( ( el ) => el.textContent === 'ready' ).toString(); - - // Increment exec times everytime this function runs. - state.execTimes.afterLoad += 1; - } - - const sharedAfterLoad = ({ state }) => { - // Increment exec times everytime this function runs. - state.execTimes.sharedAfterLoad += 1; - } - - // Case 1: without afterload callback - store( { - state: { status1: 'ready' }, - } ); - - // Case 2: non-shared afterload callback - store( { - state: { - status2: 'ready', - allStoresReady: false, - vdomReady: false, - execTimes: { afterLoad: 0 }, - }, - }, { afterLoad } ); - - // Case 3: shared afterload callback - store( { - state: { - status3: 'ready', - execTimes: { sharedAfterLoad: 0 }, - }, - }, { afterLoad: sharedAfterLoad } ); - store( { - state: { - status4: 'ready', - execTimes: { sharedAfterLoad: 0 }, - }, - }, { afterLoad: sharedAfterLoad } ); -} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php index 9bc8126720b9b9..7bab5b3f5d58b5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -10,7 +10,7 @@ $test_store_tag_counter = 'ok' === $attributes['condition'] ? 3 : 0; $test_store_tag_double = $test_store_tag_counter * 2; ?> -
      +
      Counter: + HTML; diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js index 140cab6463137f..99bbf93cf18e0f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -4,17 +4,19 @@ */ const { store } = wp.interactivity; - store( { + const { state } = store( 'store-tag', { state: { counter: { // `value` is defined in the server. - double: ( { state } ) => state.counter.value * 2, + get double() { + return state.counter.value * 2 + }, clicks: 0, }, }, actions: { counter: { - increment: ( { state } ) => { + increment() { state.counter.value += 1; state.counter.clicks += 1; }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php index a3ebb7a87424e4..581ac2c2b2664e 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -13,7 +13,7 @@
      -
      +
      This should not be shown because it is inside an island. @@ -21,7 +21,7 @@
      -
      +
      -
      -
      +
      +
      -
      +
      -
      +
      { - const { store, directive, createElement } = wp.interactivity; + const { store, directive, createElement: h } = wp.interactivity; // Fake `data-wp-show-mock` directive to test when things are removed from the // DOM. Replace with `data-wp-show` when it's ready. - directive( +directive( 'show-mock', ( { - directives: { - "show-mock": { default: showMock }, - }, + directives: { 'show-mock': showMock }, element, evaluate, } ) => { - if ( ! evaluate( showMock ) ) + const entry = showMock.find( + ( { suffix} ) => suffix === 'default' + ); + + if ( ! evaluate( entry ) ) { element.props.children = - createElement( "template", null, element.props.children ); + h( "template", null, element.props.children ); + } } ); - store( { + store( 'tovdom-islands', { state: { falseValue: false, }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php index 952a4f6c0a455d..06c5f404220b91 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -10,7 +10,7 @@ $src_cdata = $plugin_url . 'tovdom/cdata.js'; ?> -
      +
      diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js index 734ccbd801bb1e..3051680d224143 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js @@ -1,5 +1,5 @@ ( ( { wp } ) => { const { store } = wp.interactivity; - store( {} ); + store( 'tovdom', {} ); } )( window ); diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index fd2491695be5ad..3fddcc531fb935 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -24,6 +24,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "types": "build-types", "dependencies": { "@preact/signals": "^1.1.3", "deepsignal": "^1.3.6", diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index ce3859c630231f..0793dc0cc5d5ba 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -17,6 +17,7 @@ import { createPortal } from './portals'; import { useSignalEffect } from './utils'; import { directive } from './hooks'; import { SlotProvider, Slot, Fill } from './slots'; +import { navigate } from './router'; const isObject = ( item ) => item && typeof item === 'object' && ! Array.isArray( item ); @@ -40,21 +41,24 @@ export default () => { directive( 'context', ( { - directives: { - context: { default: newContext }, - }, + directives: { context }, props: { children }, context: inheritedContext, } ) => { const { Provider } = inheritedContext; const inheritedValue = useContext( inheritedContext ); const currentValue = useRef( deepSignal( {} ) ); + const passedValues = context.map( ( { value } ) => value ); + currentValue.current = useMemo( () => { - const newValue = deepSignal( newContext ); + const newValue = context + .map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) ) + .reduceRight( mergeDeepSignals ); + mergeDeepSignals( newValue, inheritedValue ); mergeDeepSignals( currentValue.current, newValue, true ); return currentValue.current; - }, [ newContext, inheritedValue ] ); + }, [ inheritedValue, ...passedValues ] ); return ( { children } @@ -68,32 +72,25 @@ export default () => { return createPortal( children, document.body ); } ); - // data-wp-effect--[name] - directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( effect ).forEach( ( path ) => { - useSignalEffect( () => { - return evaluate( path, { context: contextValue } ); - } ); + // data-wp-watch--[name] + directive( 'watch', ( { directives: { watch }, evaluate } ) => { + watch.forEach( ( entry ) => { + useSignalEffect( () => evaluate( entry ) ); } ); } ); // data-wp-init--[name] - directive( 'init', ( { directives: { init }, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.values( init ).forEach( ( path ) => { - useEffect( () => { - return evaluate( path, { context: contextValue } ); - }, [] ); + directive( 'init', ( { directives: { init }, evaluate } ) => { + init.forEach( ( entry ) => { + useEffect( () => evaluate( entry ), [] ); } ); } ); // data-wp-on--[event] - directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { - const contextValue = useContext( context ); - Object.entries( on ).forEach( ( [ name, path ] ) => { - element.props[ `on${ name }` ] = ( event ) => { - evaluate( path, { event, context: contextValue } ); + directive( 'on', ( { directives: { on }, element, evaluate } ) => { + on.forEach( ( entry ) => { + element.props[ `on${ entry.suffix }` ] = ( event ) => { + evaluate( entry, event ); }; } ); } ); @@ -101,20 +98,12 @@ export default () => { // data-wp-class--[classname] directive( 'class', - ( { - directives: { class: className }, - element, - evaluate, - context, - } ) => { - const contextValue = useContext( context ); - Object.keys( className ) - .filter( ( n ) => n !== 'default' ) - .forEach( ( name ) => { - const result = evaluate( className[ name ], { - className: name, - context: contextValue, - } ); + ( { directives: { class: className }, element, evaluate } ) => { + className + .filter( ( { suffix } ) => suffix !== 'default' ) + .forEach( ( entry ) => { + const name = entry.suffix; + const result = evaluate( entry, { className: name } ); const currentClass = element.props.class || ''; const classFinder = new RegExp( `(^|\\s)${ name }(\\s|$)`, @@ -179,111 +168,142 @@ export default () => { }; // data-wp-style--[style-key] - directive( - 'style', - ( { directives: { style }, element, evaluate, context } ) => { - const contextValue = useContext( context ); - Object.keys( style ) - .filter( ( n ) => n !== 'default' ) - .forEach( ( key ) => { - const result = evaluate( style[ key ], { - key, - context: contextValue, - } ); - element.props.style = element.props.style || {}; - if ( typeof element.props.style === 'string' ) - element.props.style = cssStringToObject( - element.props.style - ); - if ( ! result ) delete element.props.style[ key ]; - else element.props.style[ key ] = result; + directive( 'style', ( { directives: { style }, element, evaluate } ) => { + style + .filter( ( { suffix } ) => suffix !== 'default' ) + .forEach( ( entry ) => { + const key = entry.suffix; + const result = evaluate( entry, { key } ); + element.props.style = element.props.style || {}; + if ( typeof element.props.style === 'string' ) + element.props.style = cssStringToObject( + element.props.style + ); + if ( ! result ) delete element.props.style[ key ]; + else element.props.style[ key ] = result; - useEffect( () => { - // This seems necessary because Preact doesn't change the styles on - // the hydration, so we have to do it manually. It doesn't need deps - // because it only needs to do it the first time. - if ( ! result ) { - element.ref.current.style.removeProperty( key ); - } else { - element.ref.current.style[ key ] = result; - } - }, [] ); - } ); - } - ); + useEffect( () => { + // This seems necessary because Preact doesn't change the styles on + // the hydration, so we have to do it manually. It doesn't need deps + // because it only needs to do it the first time. + if ( ! result ) { + element.ref.current.style.removeProperty( key ); + } else { + element.ref.current.style[ key ] = result; + } + }, [] ); + } ); + } ); // data-wp-bind--[attribute] + directive( 'bind', ( { directives: { bind }, element, evaluate } ) => { + bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach( + ( entry ) => { + const attribute = entry.suffix; + const result = evaluate( entry ); + element.props[ attribute ] = result; + // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. + // We need this workaround until the following issue is solved: + // https://github.com/preactjs/preact/issues/4136 + useLayoutEffect( () => { + if ( + attribute === 'role' && + ( result === null || result === undefined ) + ) { + element.ref.current.removeAttribute( attribute ); + } + }, [ attribute, result ] ); + + // This seems necessary because Preact doesn't change the attributes + // on the hydration, so we have to do it manually. It doesn't need + // deps because it only needs to do it the first time. + useEffect( () => { + const el = element.ref.current; + + // We set the value directly to the corresponding + // HTMLElement instance property excluding the following + // special cases. + // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 + if ( + attribute !== 'width' && + attribute !== 'height' && + attribute !== 'href' && + attribute !== 'list' && + attribute !== 'form' && + // Default value in browsers is `-1` and an empty string is + // cast to `0` instead + attribute !== 'tabIndex' && + attribute !== 'download' && + attribute !== 'rowSpan' && + attribute !== 'colSpan' && + attribute !== 'role' && + attribute in el + ) { + try { + el[ attribute ] = + result === null || result === undefined + ? '' + : result; + return; + } catch ( err ) {} + } + // aria- and data- attributes have no boolean representation. + // A `false` value is different from the attribute not being + // present, so we can't remove it. + // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 + if ( + result !== null && + result !== undefined && + ( result !== false || attribute[ 4 ] === '-' ) + ) { + el.setAttribute( attribute, result ); + } else { + el.removeAttribute( attribute ); + } + }, [] ); + } + ); + } ); + + // data-wp-navigation-link directive( - 'bind', - ( { directives: { bind }, element, context, evaluate } ) => { - const contextValue = useContext( context ); - Object.entries( bind ) - .filter( ( n ) => n !== 'default' ) - .forEach( ( [ attribute, path ] ) => { - const result = evaluate( path, { - context: contextValue, - } ); - element.props[ attribute ] = result; - // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. - // We need this workaround until the following issue is solved: - // https://github.com/preactjs/preact/issues/4136 - useLayoutEffect( () => { - if ( - attribute === 'role' && - ( result === null || result === undefined ) - ) { - element.ref.current.removeAttribute( attribute ); - } - }, [ attribute, result ] ); + 'navigation-link', + ( { + directives: { 'navigation-link': navigationLink }, + props: { href }, + element, + } ) => { + const { value: link } = navigationLink.find( + ( { suffix } ) => suffix === 'default' + ); - // This seems necessary because Preact doesn't change the attributes - // on the hydration, so we have to do it manually. It doesn't need - // deps because it only needs to do it the first time. - useEffect( () => { - const el = element.ref.current; - - // We set the value directly to the corresponding - // HTMLElement instance property excluding the following - // special cases. - // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 - if ( - attribute !== 'width' && - attribute !== 'height' && - attribute !== 'href' && - attribute !== 'list' && - attribute !== 'form' && - // Default value in browsers is `-1` and an empty string is - // cast to `0` instead - attribute !== 'tabIndex' && - attribute !== 'download' && - attribute !== 'rowSpan' && - attribute !== 'colSpan' && - attribute !== 'role' && - attribute in el - ) { - try { - el[ attribute ] = - result === null || result === undefined - ? '' - : result; - return; - } catch ( err ) {} - } - // aria- and data- attributes have no boolean representation. - // A `false` value is different from the attribute not being - // present, so we can't remove it. - // We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 - if ( - result !== null && - result !== undefined && - ( result !== false || attribute[ 4 ] === '-' ) - ) { - el.setAttribute( attribute, result ); - } else { - el.removeAttribute( attribute ); - } - }, [] ); - } ); + useEffect( () => { + // Prefetch the page if it is in the directive options. + if ( link?.prefetch ) { + // prefetch( href ); + } + } ); + + // Don't do anything if it's falsy. + if ( link !== false ) { + element.props.onclick = async ( event ) => { + event.preventDefault(); + + // Fetch the page (or return it from cache). + await navigate( href ); + + // Update the scroll, depending on the option. True by default. + if ( link?.scroll === 'smooth' ) { + window.scrollTo( { + top: 0, + left: 0, + behavior: 'smooth', + } ); + } else if ( link?.scroll !== false ) { + window.scrollTo( 0, 0 ); + } + }; + } } ); @@ -308,35 +328,20 @@ export default () => { ); // data-wp-text - directive( - 'text', - ( { - directives: { - text: { default: text }, - }, - element, - evaluate, - context, - } ) => { - const contextValue = useContext( context ); - element.props.children = evaluate( text, { - context: contextValue, - } ); - } - ); + directive( 'text', ( { directives: { text }, element, evaluate } ) => { + const entry = text.find( ( { suffix } ) => suffix === 'default' ); + element.props.children = evaluate( entry ); + } ); // data-wp-slot directive( 'slot', - ( { - directives: { - slot: { default: slot }, - }, - props: { children }, - element, - } ) => { - const name = typeof slot === 'string' ? slot : slot.name; - const position = slot.position || 'children'; + ( { directives: { slot }, props: { children }, element } ) => { + const { value } = slot.find( + ( { suffix } ) => suffix === 'default' + ); + const name = typeof value === 'string' ? value : value.name; + const position = value.position || 'children'; if ( position === 'before' ) { return ( @@ -369,16 +374,9 @@ export default () => { // data-wp-fill directive( 'fill', - ( { - directives: { - fill: { default: fill }, - }, - props: { children }, - evaluate, - context, - } ) => { - const contextValue = useContext( context ); - const slot = evaluate( fill, { context: contextValue } ); + ( { directives: { fill }, props: { children }, evaluate } ) => { + const entry = fill.find( ( { suffix } ) => suffix === 'default' ); + const slot = evaluate( entry ); return { children }; }, { priority: 4 } diff --git a/packages/interactivity/src/hooks.js b/packages/interactivity/src/hooks.tsx similarity index 63% rename from packages/interactivity/src/hooks.js rename to packages/interactivity/src/hooks.tsx index d5b019300fed1a..f782d998498621 100644 --- a/packages/interactivity/src/hooks.js +++ b/packages/interactivity/src/hooks.tsx @@ -1,12 +1,15 @@ +// @ts-nocheck + /** * External dependencies */ import { h, options, createContext, cloneElement } from 'preact'; -import { useRef, useCallback } from 'preact/hooks'; +import { useRef, useCallback, useContext } from 'preact/hooks'; +import { deepSignal } from 'deepsignal'; /** * Internal dependencies */ -import { rawStore as store } from './store'; +import { stores } from './store'; /** @typedef {import('preact').VNode} VNode */ /** @typedef {typeof context} Context */ @@ -37,6 +40,67 @@ import { rawStore as store } from './store'; // Main context. const context = createContext( {} ); +// Wrap the element props to prevent modifications. +const immutableMap = new WeakMap(); +const immutableError = () => { + throw new Error( + 'Please use `data-wp-bind` to modify the attributes of an element.' + ); +}; +const immutableHandlers = { + get( target, key, receiver ) { + const value = Reflect.get( target, key, receiver ); + return !! value && typeof value === 'object' + ? deepImmutable( value ) + : value; + }, + set: immutableError, + deleteProperty: immutableError, +}; +const deepImmutable = < T extends Object = {} >( target: T ): T => { + if ( ! immutableMap.has( target ) ) + immutableMap.set( target, new Proxy( target, immutableHandlers ) ); + return immutableMap.get( target ); +}; + +// Store stacks for the current scope and the default namespaces and export APIs +// to interact with them. +const scopeStack: any[] = []; +const namespaceStack: string[] = []; + +export const getContext = < T extends object >( namespace?: string ): T => + getScope()?.context[ namespace || namespaceStack.slice( -1 )[ 0 ] ]; + +export const getElement = () => { + if ( ! getScope() ) { + throw Error( + 'Cannot call `getElement()` outside getters and actions used by directives.' + ); + } + const { ref, state, props } = getScope(); + return Object.freeze( { + ref: ref.current, + state, + props: deepImmutable( props ), + } ); +}; + +export const getScope = () => scopeStack.slice( -1 )[ 0 ]; + +export const setScope = ( scope ) => { + scopeStack.push( scope ); +}; +export const resetScope = () => { + scopeStack.pop(); +}; + +export const setNamespace = ( namespace: string ) => { + namespaceStack.push( namespace ); +}; +export const resetNamespace = () => { + namespaceStack.pop(); +}; + // WordPress Directives. const directiveCallbacks = {}; const directivePriorities = {}; @@ -112,29 +176,28 @@ export const directive = ( name, callback, { priority = 10 } = {} ) => { }; // Resolve the path to some property of the store object. -const resolve = ( path, ctx ) => { - let current = { ...store, context: ctx }; +const resolve = ( path, namespace ) => { + let current = { + ...stores.get( namespace ), + context: getScope().context[ namespace ], + }; path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); return current; }; // Generate the evaluate function. const getEvaluate = - ( { ref } = {} ) => - ( path, extraArgs = {} ) => { + ( { scope } = {} ) => + ( entry, ...args ) => { + let { value: path, namespace } = entry; // If path starts with !, remove it and save a flag. const hasNegationOperator = path[ 0 ] === '!' && !! ( path = path.slice( 1 ) ); - const value = resolve( path, extraArgs.context ); - const returnValue = - typeof value === 'function' - ? value( { - ref: ref.current, - ...store, - ...extraArgs, - } ) - : value; - return hasNegationOperator ? ! returnValue : returnValue; + setScope( scope ); + const value = resolve( path, namespace ); + const result = typeof value === 'function' ? value( ...args ) : value; + resetScope(); + return hasNegationOperator ? ! result : result; }; // Separate directives by priority. The resulting array contains objects @@ -153,25 +216,28 @@ const getPriorityLevels = ( directives ) => { .map( ( [ , arr ] ) => arr ); }; -// Priority level wrapper. +// Component that wraps each priority level of directives of an element. const Directives = ( { directives, priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ], element, - evaluate, originalProps, - elemRef, + previousScope = {}, } ) => { - // Initialize the DOM reference. - // eslint-disable-next-line react-hooks/rules-of-hooks - elemRef = elemRef || useRef( null ); - - // Create a reference to the evaluate function using the DOM reference. - // eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps - evaluate = evaluate || useCallback( getEvaluate( { ref: elemRef } ), [] ); + // Initialize the scope of this element. These scopes are different per each + // level because each level has a different context, but they share the same + // element ref, state and props. + const scope = useRef( {} ).current; + scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); + scope.context = useContext( context ); + /* eslint-disable react-hooks/rules-of-hooks */ + scope.ref = previousScope.ref || useRef( null ); + scope.state = previousScope.state || useRef( deepSignal( {} ) ).current; + /* eslint-enable react-hooks/rules-of-hooks */ - // Create a fresh copy of the vnode element. - element = cloneElement( element, { ref: elemRef } ); + // Create a fresh copy of the vnode element and add the props to the scope. + element = cloneElement( element, { ref: scope.ref } ); + scope.props = element.props; // Recursively render the wrapper for the next priority level. const children = @@ -180,22 +246,31 @@ const Directives = ( { directives={ directives } priorityLevels={ nextPriorityLevels } element={ element } - evaluate={ evaluate } originalProps={ originalProps } - elemRef={ elemRef } + previousScope={ scope } /> ) : ( element ); const props = { ...originalProps, children }; - const directiveArgs = { directives, props, element, context, evaluate }; + const directiveArgs = { + directives, + props, + element, + context, + evaluate: scope.evaluate, + }; + + setScope( scope ); for ( const directiveName of currentPriorityLevel ) { const wrapper = directiveCallbacks[ directiveName ]?.( directiveArgs ); if ( wrapper !== undefined ) props.children = wrapper; } + resetScope(); + return props.children; }; @@ -205,7 +280,10 @@ options.vnode = ( vnode ) => { if ( vnode.props.__directives ) { const props = vnode.props; const directives = props.__directives; - if ( directives.key ) vnode.key = directives.key.default; + if ( directives.key ) + vnode.key = directives.key.find( + ( { suffix } ) => suffix === 'default' + ).value; delete props.__directives; const priorityLevels = getPriorityLevels( directives ); if ( priorityLevels.length > 0 ) { diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 88e81e6f5877c0..6c7b98e8e7a79e 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -3,9 +3,9 @@ */ import registerDirectives from './directives'; import { init } from './router'; -import { rawStore, afterLoads } from './store'; + export { store } from './store'; -export { directive } from './hooks'; +export { directive, getContext, getElement } from './hooks'; export { navigate, prefetch } from './router'; export { h as createElement } from 'preact'; export { useEffect, useContext, useMemo } from 'preact/hooks'; @@ -14,5 +14,4 @@ export { deepSignal } from 'deepsignal'; document.addEventListener( 'DOMContentLoaded', async () => { registerDirectives(); await init(); - afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) ); } ); diff --git a/packages/interactivity/src/store.js b/packages/interactivity/src/store.js deleted file mode 100644 index e0c5f8b3fae777..00000000000000 --- a/packages/interactivity/src/store.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * External dependencies - */ -import { deepSignal } from 'deepsignal'; - -const isObject = ( item ) => - item && typeof item === 'object' && ! Array.isArray( item ); - -const deepMerge = ( target, source ) => { - if ( isObject( target ) && isObject( source ) ) { - for ( const key in source ) { - if ( isObject( source[ key ] ) ) { - if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); - deepMerge( target[ key ], source[ key ] ); - } else { - Object.assign( target, { [ key ]: source[ key ] } ); - } - } - } -}; - -const getSerializedState = () => { - const storeTag = document.querySelector( - `script[type="application/json"]#wp-interactivity-store-data` - ); - if ( ! storeTag ) return {}; - try { - const { state } = JSON.parse( storeTag.textContent ); - if ( isObject( state ) ) return state; - throw Error( 'Parsed state is not an object' ); - } catch ( e ) { - // eslint-disable-next-line no-console - console.log( e ); - } - return {}; -}; - -export const afterLoads = new Set(); - -const rawState = getSerializedState(); -export const rawStore = { state: deepSignal( rawState ) }; - -/** - * @typedef StoreProps Properties object passed to `store`. - * @property {Object} state State to be added to the global store. All the - * properties included here become reactive. - */ - -/** - * @typedef StoreOptions Options object. - * @property {(store:any) => void} [afterLoad] Callback to be executed after the - * Interactivity API has been set up - * and the store is ready. It - * receives the store as argument. - */ - -/** - * Extends the Interactivity API global store with the passed properties. - * - * These props typically consist of `state`, which is reactive, and other - * properties like `selectors`, `actions`, `effects`, etc. which can store - * callbacks and derived state. These props can then be referenced by any - * directive to make the HTML interactive. - * - * @example - * ```js - * store({ - * state: { - * counter: { value: 0 }, - * }, - * actions: { - * counter: { - * increment: ({ state }) => { - * state.counter.value += 1; - * }, - * }, - * }, - * }); - * ``` - * - * The code from the example above allows blocks to subscribe and interact with - * the store by using directives in the HTML, e.g.: - * - * ```html - *
      - * - *
      - * ``` - * - * @param {StoreProps} properties Properties to be added to the global store. - * @param {StoreOptions} [options] Options passed to the `store` call. - */ -export const store = ( { state, ...block }, { afterLoad } = {} ) => { - deepMerge( rawStore, block ); - deepMerge( rawState, state ); - if ( afterLoad ) afterLoads.add( afterLoad ); -}; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts new file mode 100644 index 00000000000000..1e9ab7e1a8f46b --- /dev/null +++ b/packages/interactivity/src/store.ts @@ -0,0 +1,289 @@ +/** + * External dependencies + */ +import { deepSignal } from 'deepsignal'; +import { computed } from '@preact/signals'; + +/** + * Internal dependencies + */ +import { + getScope, + setScope, + resetScope, + setNamespace, + resetNamespace, +} from './hooks'; + +const isObject = ( item: unknown ): boolean => + !! item && typeof item === 'object' && ! Array.isArray( item ); + +const deepMerge = ( target: any, source: any ) => { + if ( isObject( target ) && isObject( source ) ) { + for ( const key in source ) { + const getter = Object.getOwnPropertyDescriptor( source, key )?.get; + if ( typeof getter === 'function' ) { + Object.defineProperty( target, key, { get: getter } ); + } else if ( isObject( source[ key ] ) ) { + if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); + deepMerge( target[ key ], source[ key ] ); + } else { + Object.assign( target, { [ key ]: source[ key ] } ); + } + } + } +}; + +const parseInitialState = () => { + const storeTag = document.querySelector( + `script[type="application/json"]#wp-interactivity-initial-state` + ); + if ( ! storeTag?.textContent ) return {}; + try { + const initialState = JSON.parse( storeTag.textContent ); + if ( isObject( initialState ) ) return initialState; + throw Error( 'Parsed state is not an object' ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.log( e ); + } + return {}; +}; + +export const stores = new Map(); +const rawStores = new Map(); +const storeLocks = new Map(); + +const objToProxy = new WeakMap(); +const proxyToNs = new WeakMap(); +const scopeToGetters = new WeakMap(); + +const proxify = ( obj: any, ns: string ) => { + if ( ! objToProxy.has( obj ) ) { + const proxy = new Proxy( obj, handlers ); + objToProxy.set( obj, proxy ); + proxyToNs.set( proxy, ns ); + } + return objToProxy.get( obj ); +}; + +const handlers = { + get: ( target: any, key: string | symbol, receiver: any ) => { + const ns = proxyToNs.get( receiver ); + + // Check if the property is a getter and we are inside an scope. If that is + // the case, we clone the getter to avoid overwriting the scoped + // dependencies of the computed each time that getter runs. + const getter = Object.getOwnPropertyDescriptor( target, key )?.get; + if ( getter ) { + const scope = getScope(); + if ( scope ) { + const getters = + scopeToGetters.get( scope ) || + scopeToGetters.set( scope, new Map() ).get( scope ); + if ( ! getters.has( getter ) ) { + getters.set( + getter, + computed( () => { + setNamespace( ns ); + setScope( scope ); + try { + return getter.call( target ); + } finally { + resetScope(); + resetNamespace(); + } + } ) + ); + } + return getters.get( getter ).value; + } + } + + const result = Reflect.get( target, key, receiver ); + + // Check if the proxy is the store root and no key with that name exist. In + // that case, return an empty object for the requested key. + if ( typeof result === 'undefined' && receiver === stores.get( ns ) ) { + const obj = {}; + Reflect.set( target, key, obj, receiver ); + return proxify( obj, ns ); + } + + // Check if the property is a generator. If it is, we turn it into an + // asynchronous function where we restore the default namespace and scope + // each time it awaits/yields. + if ( result?.constructor?.name === 'GeneratorFunction' ) { + return async ( ...args: unknown[] ) => { + const scope = getScope(); + const gen: Generator< any > = result( ...args ); + + let value: any; + let it: IteratorResult< any >; + + while ( true ) { + setNamespace( ns ); + setScope( scope ); + try { + it = gen.next( value ); + } finally { + resetScope(); + resetNamespace(); + } + + try { + value = await it.value; + } catch ( e ) { + gen.throw( e ); + } + + if ( it.done ) break; + } + + return value; + }; + } + + // Check if the property is a synchronous function. If it is, set the + // default namespace. Synchronous functions always run in the proper scope, + // which is set by the Directives component. + if ( typeof result === 'function' ) { + return ( ...args: unknown[] ) => { + setNamespace( ns ); + try { + return result( ...args ); + } finally { + resetNamespace(); + } + }; + } + + // Check if the property is an object. If it is, proxyify it. + if ( isObject( result ) ) return proxify( result, ns ); + + return result; + }, +}; + +/** + * @typedef StoreProps Properties object passed to `store`. + * @property {Object} state State to be added to the global store. All the + * properties included here become reactive. + */ + +/** + * @typedef StoreOptions Options object. + */ + +/** + * Extends the Interactivity API global store with the passed properties. + * + * These props typically consist of `state`, which is reactive, and other + * properties like `selectors`, `actions`, `effects`, etc. which can store + * callbacks and derived state. These props can then be referenced by any + * directive to make the HTML interactive. + * + * @example + * ```js + * store({ + * state: { + * counter: { value: 0 }, + * }, + * actions: { + * counter: { + * increment: ({ state }) => { + * state.counter.value += 1; + * }, + * }, + * }, + * }); + * ``` + * + * The code from the example above allows blocks to subscribe and interact with + * the store by using directives in the HTML, e.g.: + * + * ```html + *
      + * + *
      + * ``` + * + * @param {StoreProps} properties Properties to be added to the global store. + * @param {StoreOptions} [options] Options passed to the `store` call. + */ + +interface StoreOptions { + lock?: boolean | string; +} + +const universalUnlock = + 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; + +export function store< S extends object = {} >( + namespace: string, + storePart?: S, + options?: StoreOptions +): S; +export function store< T extends object >( + namespace: string, + storePart?: T, + options?: StoreOptions +): T; + +export function store( + namespace: string, + { state = {}, ...block }: any = {}, + { lock = false }: StoreOptions = {} +) { + if ( ! stores.has( namespace ) ) { + // Lock the store if the passed lock is different from the universal + // unlock. Once the lock is set (either false, true, or a given string), + // it cannot change. + if ( lock !== universalUnlock ) { + storeLocks.set( namespace, lock ); + } + const rawStore = { state: deepSignal( state ), ...block }; + const proxiedStore = new Proxy( rawStore, handlers ); + rawStores.set( namespace, rawStore ); + stores.set( namespace, proxiedStore ); + proxyToNs.set( proxiedStore, namespace ); + } else { + // Lock the store if it wasn't locked yet and the passed lock is + // different from the universal unlock. If no lock is given, the store + // will be public and won't accept any lock from now on. + if ( lock !== universalUnlock && ! storeLocks.has( namespace ) ) { + storeLocks.set( namespace, lock ); + } else { + const storeLock = storeLocks.get( namespace ); + const isLockValid = + lock === universalUnlock || + ( lock !== true && lock === storeLock ); + + if ( ! isLockValid ) { + if ( ! storeLock ) { + throw Error( 'Cannot lock a public store' ); + } else { + throw Error( + 'Cannot unlock a private store with an invalid lock code' + ); + } + } + } + + const target = rawStores.get( namespace ); + deepMerge( target, block ); + deepMerge( target.state, state ); + } + + return stores.get( namespace ); +} + +// Parse and populate the initial state. +Object.entries( parseInitialState() ).forEach( ( [ namespace, state ] ) => { + store( namespace, { state } ); +} ); diff --git a/packages/interactivity/src/vdom.js b/packages/interactivity/src/vdom.js index 1cf4a91ec1ead5..b1342ac271a8e2 100644 --- a/packages/interactivity/src/vdom.js +++ b/packages/interactivity/src/vdom.js @@ -10,6 +10,7 @@ import { directivePrefix as p } from './constants'; const ignoreAttr = `data-${ p }-ignore`; const islandAttr = `data-${ p }-interactive`; const fullPrefix = `data-${ p }-`; +let namespace = null; // Regular expression for directive parsing. const directiveParser = new RegExp( @@ -25,6 +26,12 @@ const directiveParser = new RegExp( 'i' // Case insensitive. ); +// Regular expression for reference parsing. It can contain a namespace before +// the reference, separated by `::`, like `some-namespace::state.somePath`. +// Namespaces can contain any alphanumeric characters, hyphens, underscores or +// forward slashes. References don't have any restrictions. +const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; + export const hydratedIslands = new WeakSet(); // Recursive function that transforms a DOM tree into vDOM. @@ -51,8 +58,7 @@ export function toVdom( root ) { const props = {}; const children = []; - const directives = {}; - let hasDirectives = false; + const directives = []; let ignore = false; let island = false; @@ -64,17 +70,19 @@ export function toVdom( root ) { ) { if ( n === ignoreAttr ) { ignore = true; - } else if ( n === islandAttr ) { - island = true; } else { - hasDirectives = true; - let val = attributes[ i ].value; + let [ ns, value ] = nsPathRegExp + .exec( attributes[ i ].value ) + ?.slice( 1 ) ?? [ null, attributes[ i ].value ]; try { - val = JSON.parse( val ); + value = JSON.parse( value ); } catch ( e ) {} - const [ , prefix, suffix ] = directiveParser.exec( n ); - directives[ prefix ] = directives[ prefix ] || {}; - directives[ prefix ][ suffix || 'default' ] = val; + if ( n === islandAttr ) { + island = true; + namespace = value?.namespace ?? null; + } else { + directives.push( [ n, ns, value ] ); + } } } else if ( n === 'ref' ) { continue; @@ -92,7 +100,22 @@ export function toVdom( root ) { ]; if ( island ) hydratedIslands.add( node ); - if ( hasDirectives ) props.__directives = directives; + if ( directives.length ) { + props.__directives = directives.reduce( + ( obj, [ name, ns, value ] ) => { + const [ , prefix, suffix = 'default' ] = + directiveParser.exec( name ); + if ( ! obj[ prefix ] ) obj[ prefix ] = []; + obj[ prefix ].push( { + namespace: ns ?? namespace, + value, + suffix, + } ); + return obj; + }, + {} + ); + } let child = treeWalker.firstChild(); if ( child ) { diff --git a/packages/interactivity/tsconfig.json b/packages/interactivity/tsconfig.json new file mode 100644 index 00000000000000..bcb26904e1d09d --- /dev/null +++ b/packages/interactivity/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "checkJs": false, + "strict": false + }, + "include": [ "src/**/*" ] +} diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index 2d81bfdb513b33..796c4ae098867c 100644 --- a/phpunit/blocks/render-query-test.php +++ b/phpunit/blocks/render-query-test.php @@ -68,32 +68,32 @@ public function test_rendering_query_with_enhanced_pagination() { $p = new WP_HTML_Tag_Processor( $output ); $p->next_tag( array( 'class_name' => 'wp-block-query' ) ); - $this->assertSame( '{"core":{"query":{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}}}', $p->get_attribute( 'data-wp-context' ) ); + $this->assertSame( '{"loadingText":"Loading page, please wait.","loadedText":"Page Loaded."}', $p->get_attribute( 'data-wp-context' ) ); $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-navigation-id' ) ); - $this->assertSame( true, $p->get_attribute( 'data-wp-interactive' ) ); + $this->assertSame( '{"namespace":"core/query"}', $p->get_attribute( 'data-wp-interactive' ) ); $p->next_tag( array( 'class_name' => 'wp-block-post' ) ); $this->assertSame( 'post-template-item-' . self::$posts[1], $p->get_attribute( 'data-wp-key' ) ); $p->next_tag( array( 'class_name' => 'wp-block-query-pagination-previous' ) ); $this->assertSame( 'query-pagination-previous', $p->get_attribute( 'data-wp-key' ) ); - $this->assertSame( 'actions.core.query.navigate', $p->get_attribute( 'data-wp-on--click' ) ); - $this->assertSame( 'actions.core.query.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) ); - $this->assertSame( 'effects.core.query.prefetch', $p->get_attribute( 'data-wp-effect' ) ); + $this->assertSame( 'core/query::actions.navigate', $p->get_attribute( 'data-wp-on--click' ) ); + $this->assertSame( 'core/query::actions.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) ); + $this->assertSame( 'core/query::callbacks.prefetch', $p->get_attribute( 'data-wp-watch' ) ); $p->next_tag( array( 'class_name' => 'wp-block-query-pagination-next' ) ); $this->assertSame( 'query-pagination-next', $p->get_attribute( 'data-wp-key' ) ); - $this->assertSame( 'actions.core.query.navigate', $p->get_attribute( 'data-wp-on--click' ) ); - $this->assertSame( 'actions.core.query.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) ); - $this->assertSame( 'effects.core.query.prefetch', $p->get_attribute( 'data-wp-effect' ) ); + $this->assertSame( 'core/query::actions.navigate', $p->get_attribute( 'data-wp-on--click' ) ); + $this->assertSame( 'core/query::actions.prefetch', $p->get_attribute( 'data-wp-on--mouseenter' ) ); + $this->assertSame( 'core/query::callbacks.prefetch', $p->get_attribute( 'data-wp-watch' ) ); $p->next_tag( array( 'class_name' => 'screen-reader-text' ) ); $this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) ); - $this->assertSame( 'context.core.query.message', $p->get_attribute( 'data-wp-text' ) ); + $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) ); $p->next_tag( array( 'class_name' => 'wp-block-query__enhanced-pagination-animation' ) ); - $this->assertSame( 'selectors.core.query.startAnimation', $p->get_attribute( 'data-wp-class--start-animation' ) ); - $this->assertSame( 'selectors.core.query.finishAnimation', $p->get_attribute( 'data-wp-class--finish-animation' ) ); + $this->assertSame( 'state.startAnimation', $p->get_attribute( 'data-wp-class--start-animation' ) ); + $this->assertSame( 'state.finishAnimation', $p->get_attribute( 'data-wp-class--finish-animation' ) ); } /** @@ -170,7 +170,7 @@ public function test_enhanced_query_markup_rendering_at_bottom_on_custom_html_el $this->assertSame( $p->next_tag(), true ); // Test that that div is the accesibility one. $this->assertSame( 'screen-reader-text', $p->get_attribute( 'class' ) ); - $this->assertSame( 'context.core.query.message', $p->get_attribute( 'data-wp-text' ) ); + $this->assertSame( 'context.message', $p->get_attribute( 'data-wp-text' ) ); $this->assertSame( 'polite', $p->get_attribute( 'aria-live' ) ); } diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php index 22205289b20bee..837d6fd50f193a 100644 --- a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php @@ -161,7 +161,7 @@ public function test_store_should_be_correctly_rendered() { WP_Interactivity_Store::render(); $rendered = ob_get_clean(); $this->assertSame( - '', + '', $rendered ); } @@ -179,7 +179,7 @@ public function test_store_should_also_escape_tags_and_amps() { WP_Interactivity_Store::render(); $rendered = ob_get_clean(); $this->assertSame( - '', + '', $rendered ); } diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-watch.spec.ts similarity index 79% rename from test/e2e/specs/interactivity/directive-effect.spec.ts rename to test/e2e/specs/interactivity/directive-watch.spec.ts index 40030d257661fc..09bd0214c0a51e 100644 --- a/test/e2e/specs/interactivity/directive-effect.spec.ts +++ b/test/e2e/specs/interactivity/directive-watch.spec.ts @@ -3,14 +3,14 @@ */ import { test, expect } from './fixtures'; -test.describe( 'data-wp-effect', () => { +test.describe( 'data-wp-watch', () => { test.beforeAll( async ( { interactivityUtils: utils } ) => { await utils.activatePlugins(); - await utils.addPostWithBlock( 'test/directive-effect' ); + await utils.addPostWithBlock( 'test/directive-watch' ); } ); test.beforeEach( async ( { interactivityUtils: utils, page } ) => { - await page.goto( utils.getLink( 'test/directive-effect' ) ); + await page.goto( utils.getLink( 'test/directive-watch' ) ); } ); test.afterAll( async ( { interactivityUtils: utils } ) => { @@ -18,12 +18,12 @@ test.describe( 'data-wp-effect', () => { await utils.deleteAllPosts(); } ); - test( 'check that effect runs when it is added', async ( { page } ) => { + test( 'check that watch runs when it is added', async ( { page } ) => { const el = page.getByTestId( 'element in the DOM' ); await expect( el ).toContainText( 'element is in the DOM' ); } ); - test( 'check that effect runs when it is removed', async ( { page } ) => { + test( 'check that watch runs when it is removed', async ( { page } ) => { await page.getByTestId( 'toggle' ).click(); const el = page.getByTestId( 'element in the DOM' ); await expect( el ).toContainText( 'element is not in the DOM' ); diff --git a/test/e2e/specs/interactivity/store-afterload.spec.ts b/test/e2e/specs/interactivity/store-afterload.spec.ts deleted file mode 100644 index 388e80177b0339..00000000000000 --- a/test/e2e/specs/interactivity/store-afterload.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Internal dependencies - */ -import { test, expect } from './fixtures'; - -test.describe( 'store afterLoad callbacks', () => { - test.beforeAll( async ( { interactivityUtils: utils } ) => { - await utils.activatePlugins(); - await utils.addPostWithBlock( 'test/store-afterload' ); - } ); - - test.beforeEach( async ( { interactivityUtils: utils, page } ) => { - await page.goto( utils.getLink( 'test/store-afterload' ) ); - } ); - - test.afterAll( async ( { interactivityUtils: utils } ) => { - await utils.deactivatePlugins(); - await utils.deleteAllPosts(); - } ); - - test( 'run after the vdom and store are ready', async ( { page } ) => { - const allStoresReady = page.getByTestId( 'all-stores-ready' ); - const vdomReady = page.getByTestId( 'vdom-ready' ); - - await expect( allStoresReady ).toHaveText( 'true' ); - await expect( vdomReady ).toHaveText( 'true' ); - } ); - - test( 'run once even if shared between several store calls', async ( { - page, - } ) => { - const afterLoadTimes = page.getByTestId( 'after-load-exec-times' ); - const sharedAfterLoadTimes = page.getByTestId( - 'shared-after-load-exec-times' - ); - - await expect( afterLoadTimes ).toHaveText( '1' ); - await expect( sharedAfterLoadTimes ).toHaveText( '1' ); - } ); -} ); diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js index 26e49966ad40c3..e70bf476062284 100644 --- a/tools/webpack/interactivity.js +++ b/tools/webpack/interactivity.js @@ -25,6 +25,9 @@ module.exports = { filename: './build/interactivity/[name].min.js', path: join( __dirname, '..', '..' ), }, + resolve: { + extensions: [ '.js', '.ts', '.tsx' ], + }, module: { rules: [ { @@ -39,6 +42,7 @@ module.exports = { babelrc: false, configFile: false, presets: [ + '@babel/preset-typescript', [ '@babel/preset-react', { diff --git a/tsconfig.json b/tsconfig.json index 4ee1787a247cf7..d05e883ed70b03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ { "path": "packages/html-entities" }, { "path": "packages/i18n" }, { "path": "packages/icons" }, + { "path": "packages/interactivity" }, { "path": "packages/is-shallow-equal" }, { "path": "packages/keycodes" }, { "path": "packages/lazy-import" }, From 9d43d5098b354b581e2c92c14a5cc8d38f08d43c Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Mon, 27 Nov 2023 19:01:57 -0700 Subject: [PATCH 11/23] HTML API: Backport updates from Core (#56578) - Fix typo in Tag Processor example comment. - Rewrite @todo comments. - Add support for new tags in HTML Processor: - ADDRESS, ARTICLE, ASIDE, CENTER, DETAILS, DIALOG, - DIR, DL, FIELDSET, FOOTER, HEADER, HGROUP, MAIN, - MENU, NAV, SEARCH, SECTION, SUMMARY --- ...class-gutenberg-html-tag-processor-6-4.php | 6 +- .../html-api/class-wp-html-processor.php | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php b/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php index 509d2c1a2c9abd..e22b4fb17b902e 100644 --- a/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php +++ b/lib/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php @@ -116,7 +116,7 @@ * * Example: * - * if ( $tags->next_tag( array( 'class' => 'wp-group-block' ) ) ) { + * if ( $tags->next_tag( array( 'class_name' => 'wp-group-block' ) ) ) { * $tags->set_attribute( 'title', 'This groups the contained content.' ); * $tags->remove_attribute( 'data-test-id' ); * } @@ -2031,8 +2031,8 @@ public function set_attribute( $name, $value ) { * * @see https://html.spec.whatwg.org/#attributes-2 * - * @TODO as the only regex pattern maybe we should take it out? are - * Unicode patterns available broadly in Core? + * @todo As the only regex pattern maybe we should take it out? + * Are Unicode patterns available broadly in Core? */ if ( preg_match( '~[' . diff --git a/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php b/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php index e53e64c80e2e02..d1c8b9e82c708a 100644 --- a/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php +++ b/lib/compat/wordpress-6.4/html-api/class-wp-html-processor.php @@ -103,12 +103,16 @@ * * The following list specifies the HTML tags that _are_ supported: * + * - Containers: ADDRESS, BLOCKQUOTE, DETAILS, DIALOG, DIV, FOOTER, HEADER, MAIN, MENU, SPAN, SUMMARY. + * - Form elements: BUTTON, FIELDSET, SEARCH. + * - Formatting elements: B, BIG, CODE, EM, FONT, I, SMALL, STRIKE, STRONG, TT, U. + * - Heading elements: HGROUP. * - Links: A. - * - The formatting elements: B, BIG, CODE, EM, FONT, I, SMALL, STRIKE, STRONG, TT, U. - * - Containers: DIV, FIGCAPTION, FIGURE, SPAN. - * - Form elements: BUTTON. + * - Lists: DL. + * - Media elements: FIGCAPTION, FIGURE, IMG. * - Paragraph: P. - * - Void elements: IMG. + * - Sectioning elements: ARTICLE, ASIDE, NAV, SECTION + * - Deprecated elements: CENTER, DIR * * ### Supported markup * @@ -346,7 +350,7 @@ public function get_last_error() { /** * Finds the next tag matching the $query. * - * @TODO: Support matching the class name and tag name. + * @todo Support matching the class name and tag name. * * @since 6.4.0 * @@ -555,9 +559,9 @@ public function step( $node_to_process = self::PROCESS_NEXT_NODE ) { * Breadcrumbs start at the outermost parent and descend toward the matched element. * They always include the entire path from the root HTML node to the matched element. * - * @TODO: It could be more efficient to expose a generator-based version of this function - * to avoid creating the array copy on tag iteration. If this is done, it would likely - * be more useful to walk up the stack when yielding instead of starting at the top. + * @todo It could be more efficient to expose a generator-based version of this function + * to avoid creating the array copy on tag iteration. If this is done, it would likely + * be more useful to walk up the stack when yielding instead of starting at the top. * * Example * @@ -625,11 +629,29 @@ private function step_in_body() { * > "fieldset", "figcaption", "figure", "footer", "header", "hgroup", * > "main", "menu", "nav", "ol", "p", "search", "section", "summary", "ul" */ + case '+ADDRESS': + case '+ARTICLE': + case '+ASIDE': case '+BLOCKQUOTE': + case '+CENTER': + case '+DETAILS': + case '+DIALOG': + case '+DIR': case '+DIV': + case '+DL': + case '+FIELDSET': case '+FIGCAPTION': case '+FIGURE': + case '+FOOTER': + case '+HEADER': + case '+HGROUP': + case '+MAIN': + case '+MENU': + case '+NAV': case '+P': + case '+SEARCH': + case '+SECTION': + case '+SUMMARY': if ( $this->state->stack_of_open_elements->has_p_in_button_scope() ) { $this->close_a_p_element(); } @@ -643,11 +665,29 @@ private function step_in_body() { * > "figcaption", "figure", "footer", "header", "hgroup", "listing", "main", * > "menu", "nav", "ol", "pre", "search", "section", "summary", "ul" */ + case '-ADDRESS': + case '-ARTICLE': + case '-ASIDE': case '-BLOCKQUOTE': case '-BUTTON': + case '-CENTER': + case '-DETAILS': + case '-DIALOG': + case '-DIR': case '-DIV': + case '-DL': + case '-FIELDSET': case '-FIGCAPTION': case '-FIGURE': + case '-FOOTER': + case '-HEADER': + case '-HGROUP': + case '-MAIN': + case '-MENU': + case '-NAV': + case '-SEARCH': + case '-SECTION': + case '-SUMMARY': if ( ! $this->state->stack_of_open_elements->has_element_in_scope( $tag_name ) ) { // @TODO: Report parse error. // Ignore the token. From a2c068d9ce9eaa1acbf155d7aeb5b377c44c6bf5 Mon Sep 17 00:00:00 2001 From: JuanMa Date: Tue, 28 Nov 2023 22:05:47 +0100 Subject: [PATCH 12/23] Fundamentals of Block Development - fix save definition (#56605) --- .../fundamentals/file-structure-of-a-block.md | 2 +- .../fundamentals/javascript-in-the-block-editor.md | 8 ++++---- .../fundamentals/registration-of-a-block.md | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/getting-started/fundamentals/file-structure-of-a-block.md b/docs/getting-started/fundamentals/file-structure-of-a-block.md index dac8dd6c338091..e38470ee306709 100644 --- a/docs/getting-started/fundamentals/file-structure-of-a-block.md +++ b/docs/getting-started/fundamentals/file-structure-of-a-block.md @@ -47,7 +47,7 @@ The `edit.js` commonly gets used to contain the React component that gets used i ### `save.js` -The `save.js` is similar to the `edit.js` file in that it exports a single React component. This component generates the static HTML markup that gets saved to the Database. +The `save.js` exports the function that returns the static HTML markup that gets saved to the Database. ### `style.(css|scss|sass)` diff --git a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md index 0ca88e1447437e..73c6a6c56e6328 100644 --- a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md +++ b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md @@ -30,7 +30,7 @@ With the [proper `package.json` scripts](https://developer.wordpress.org/block-e Using Javascript without a build process may be another good option for code developments with few requirements (especially those not requiring JSX). -Without a build process, you access the methods directly from the `wp` global object and must enqueue the script manually. [WordPress Javascript](https://developer.wordpress.org/block-editor/reference-guides/packages/) packages](https://developer.wordpress.org/block-editor/reference-guides/packages/) can be accessed through the `wp` [global variable](https://developer.mozilla.org/en-US/docs/Glossary/Global_variable) but every script that wants to use them through this `wp` object is responsible for adding [the handle of that package](https://developer.wordpress.org/block-editor/contributors/code/scripts/) to the dependency array when registered. +Without a build process, you access the methods directly from the `wp` global object and must enqueue the script manually. [WordPress Javascript packages](https://developer.wordpress.org/block-editor/reference-guides/packages/) can be accessed through the `wp` [global variable](https://developer.mozilla.org/en-US/docs/Glossary/Global_variable) but every script that wants to use them through this `wp` object is responsible for adding [the handle of that package](https://developer.wordpress.org/block-editor/contributors/code/scripts/) to the dependency array when registered. So, for example if a script wants to register a block variation using the `registerBlockVariation` method out of the ["blocks" package](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-blocks/), the `wp-blocks` handle would need to get added to the dependency array to ensure that `wp.blocks.registerBlockVariation` is defined when the script tries to access it (see [example](https://github.com/wptrainingteam/block-theme-examples/blob/master/example-block-variation/functions.php)). @@ -42,10 +42,10 @@ Use [`enqueue_block_editor_assets`](https://developer.wordpress.org/reference/ho ## Additional resources +- [Get started with wp-scripts](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/) +- [Enqueueing assets in the Editor](https://developer.wordpress.org/block-editor/how-to-guides/enqueueing-assets-in-the-editor/) +- [Wordpress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) - [Javascript Reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript) | MDN Web Docs - [block-development-examples](https://github.com/WordPress/block-development-examples) | GitHub repository - [block-theme-examples](https://github.com/wptrainingteam/block-theme-examples) | GitHub repository -- [Get started with wp-scripts](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/) | Block Editor Handbook -- [Enqueueing assets in the Editor](https://developer.wordpress.org/block-editor/how-to-guides/enqueueing-assets-in-the-editor/) | Block Editor Handbook -- [Wordpress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) | Block Editor Handbook - [How webpack and WordPress packages interact](https://developer.wordpress.org/news/2023/04/how-webpack-and-wordpress-packages-interact/) | Developer Blog diff --git a/docs/getting-started/fundamentals/registration-of-a-block.md b/docs/getting-started/fundamentals/registration-of-a-block.md index 3e34f1368e8c41..7cc8e6bcbe8b06 100644 --- a/docs/getting-started/fundamentals/registration-of-a-block.md +++ b/docs/getting-started/fundamentals/registration-of-a-block.md @@ -70,7 +70,7 @@ The content of block.json (or any other .json file) ca The client-side block settings object passed as a second parameter include two properties that are especially relevant: - `edit`: The React component that gets used in the editor for our block. -- `save`: The React component that generates the static HTML markup that gets saved to the Database. +- `save`: The function that returns the static HTML markup that gets saved to the Database. `registerBlockType` returns the registered block type (`WPBlock`) on success or `undefined` on failure. @@ -78,15 +78,15 @@ The client-side block settings object passed as a second parameter include two p ```js import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; import metadata from './block.json'; +const Edit = () =>

      Hello World - Block Editor

      ; +const save = () =>

      Hello World - Frontend

      ; + registerBlockType( metadata.name, { - edit() { - return

      Hello World - Block Editor

      ; - }, - save() { - return

      Hello World - Frontend

      ; - }, + edit: Edit, + save, } ); ``` _See the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/minimal-block-ca6eda/src/index.js) in [an example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/minimal-block-ca6eda)_ From 6a74183df55882d00e4bb73102ec73e538e6aee5 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 29 Nov 2023 11:25:32 +1100 Subject: [PATCH 13/23] Core data revisions: extend support to other post types (#56353) * Initial commit: add revisions support for templates * Renames constant to be clearer about what these post types apply to. Adds wp_block and wp_navigation * Testing updating record.key in the action. * Moving `revisionKey` to the entity config and use it in the action and resolvers * Me: Pray, depart hence, thou knavish trifle! Thy utility be as absent as mirth in a grave, a jest without jesters. Begone, thou witless motley, for even a jest doth surpass thy meager worth! Empty line: *whimper* --- docs/reference-guides/data/data-core.md | 6 +--- packages/core-data/README.md | 6 +--- packages/core-data/src/actions.js | 45 +++++++++++++------------ packages/core-data/src/entities.js | 13 +++++-- packages/core-data/src/reducer.js | 2 +- packages/core-data/src/resolvers.js | 4 +-- 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index b2a75638ace9fe..b80703dcc67b18 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -741,7 +741,7 @@ _Returns_ ### receiveRevisions -Returns an action object used in signalling that revisions have been received. +Action triggered to receive revision items. _Parameters_ @@ -753,10 +753,6 @@ _Parameters_ - _invalidateCache_ `?boolean`: Should invalidate query caches. - _meta_ `?Object`: Meta information about pagination. -_Returns_ - -- `Object`: Action object. - ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. diff --git a/packages/core-data/README.md b/packages/core-data/README.md index ebc467f7fede2d..6677a32df08dc9 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -250,7 +250,7 @@ _Returns_ ### receiveRevisions -Returns an action object used in signalling that revisions have been received. +Action triggered to receive revision items. _Parameters_ @@ -262,10 +262,6 @@ _Parameters_ - _invalidateCache_ `?boolean`: Should invalidate query caches. - _meta_ `?Object`: Meta information about pagination. -_Returns_ - -- `Object`: Action object. - ### receiveThemeSupports > **Deprecated** since WP 5.9, this is not useful anymore, use the selector direclty. diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 4c5622ac780589..49776d0562984f 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -935,7 +935,7 @@ export function receiveDefaultTemplateId( query, templateId ) { } /** - * Returns an action object used in signalling that revisions have been received. + * Action triggered to receive revision items. * * @param {string} kind Kind of the received entity record revisions. * @param {string} name Name of the received entity record revisions. @@ -944,25 +944,28 @@ export function receiveDefaultTemplateId( query, templateId ) { * @param {?Object} query Query Object. * @param {?boolean} invalidateCache Should invalidate query caches. * @param {?Object} meta Meta information about pagination. - * @return {Object} Action object. */ -export function receiveRevisions( - kind, - name, - recordKey, - records, - query, - invalidateCache = false, - meta -) { - return { - type: 'RECEIVE_ITEM_REVISIONS', - items: Array.isArray( records ) ? records : [ records ], - recordKey, - meta, - query, - kind, - name, - invalidateCache, +export const receiveRevisions = + ( kind, name, recordKey, records, query, invalidateCache = false, meta ) => + async ( { dispatch } ) => { + const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const entityConfig = configs.find( + ( config ) => config.kind === kind && config.name === name + ); + const key = + entityConfig && entityConfig?.revisionKey + ? entityConfig.revisionKey + : DEFAULT_ENTITY_KEY; + + dispatch( { + type: 'RECEIVE_ITEM_REVISIONS', + key, + items: Array.isArray( records ) ? records : [ records ], + recordKey, + meta, + query, + kind, + name, + invalidateCache, + } ); }; -} diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 9f736af1c83784..f016336260ab16 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -20,8 +20,16 @@ export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; // A hardcoded list of post types that support revisions. +// Reflects post types in Core's src/wp-includes/post.php. // @TODO: Ideally this should be fetched from the `/types` REST API's view context. -const POST_TYPES_WITH_REVISIONS_SUPPORT = [ 'post', 'page' ]; +const POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT = [ + 'post', + 'page', + 'wp_block', + 'wp_navigation', + 'wp_template', + 'wp_template_part', +]; export const rootEntitiesConfig = [ { @@ -308,7 +316,7 @@ async function loadPostTypeEntities() { }, mergedEdits: { meta: true }, supports: { - revisions: POST_TYPES_WITH_REVISIONS_SUPPORT.includes( + revisions: POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT.includes( postType?.slug ), }, @@ -351,6 +359,7 @@ async function loadPostTypeEntities() { }/${ parentId }/revisions${ revisionId ? '/' + revisionId : '' }`, + revisionKey: isTemplate ? 'wp_id' : DEFAULT_ENTITY_KEY, }; } ); } diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 34558fcfbb142e..a499b42f175438 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -238,8 +238,8 @@ function entity( entityConfig ) { // Inject the entity config into the action. replaceAction( ( action ) => { return { - ...action, key: entityConfig.key || DEFAULT_ENTITY_KEY, + ...action, }; } ), ] )( diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 6e821183e3116b..245d64d05d0649 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -755,7 +755,7 @@ export const getRevisions = ...new Set( [ ...( getNormalizedCommaSeparable( query._fields ) || [] ), - DEFAULT_ENTITY_KEY, + entityConfig.revisionKey || DEFAULT_ENTITY_KEY, ] ), ].join(), }; @@ -868,7 +868,7 @@ export const getRevision = ...new Set( [ ...( getNormalizedCommaSeparable( query._fields ) || [] ), - DEFAULT_ENTITY_KEY, + entityConfig.revisionKey || DEFAULT_ENTITY_KEY, ] ), ].join(), }; From 5f947bae056eb4cb5f05b59a87876a38b449a1b7 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Nov 2023 08:33:55 +0400 Subject: [PATCH 14/23] Migrate 'block directory' e2e tests to Playwright (#56593) * Migrate 'block directory' e2e tests to Playwright * Remove old test files * Skip class locators. Co-authored-by: Bart Kalisz * Simplify the last assertion --------- Co-authored-by: Bart Kalisz --- .../block-directory-add.test.js.snap | 3 - .../plugins/block-directory-add.test.js | 205 --------------- .../editor/plugins/block-directory.spec.js | 248 ++++++++++++++++++ 3 files changed, 248 insertions(+), 208 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/plugins/__snapshots__/block-directory-add.test.js.snap delete mode 100644 packages/e2e-tests/specs/editor/plugins/block-directory-add.test.js create mode 100644 test/e2e/specs/editor/plugins/block-directory.spec.js diff --git a/packages/e2e-tests/specs/editor/plugins/__snapshots__/block-directory-add.test.js.snap b/packages/e2e-tests/specs/editor/plugins/__snapshots__/block-directory-add.test.js.snap deleted file mode 100644 index 2c06020e52c787..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/__snapshots__/block-directory-add.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`adding blocks from block directory Should be able to add (the first) block. 1`] = `""`; diff --git a/packages/e2e-tests/specs/editor/plugins/block-directory-add.test.js b/packages/e2e-tests/specs/editor/plugins/block-directory-add.test.js deleted file mode 100644 index 2e969d17915924..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/block-directory-add.test.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - searchForBlock, - insertBlockDirectoryBlock, - setUpResponseMocking, - getEditedPostContent, - createJSONResponse, -} from '@wordpress/e2e-test-utils'; - -const BLOCK1_NAME = 'block-directory-test-block/main-block'; - -// Urls to mock. -const SEARCH_URLS = [ - '/wp/v2/block-directory/search', - `rest_route=${ encodeURIComponent( '/wp/v2/block-directory/search' ) }`, -]; - -const BLOCK_TYPE_URLS = [ - `/wp/v2/block-types/${ BLOCK1_NAME }`, - `rest_route=${ encodeURIComponent( - `/wp/v2/block-types/${ BLOCK1_NAME }` - ) }`, -]; - -const INSTALL_URLS = [ - '/wp/v2/plugins', - `rest_route=${ encodeURIComponent( '/wp/v2/plugins' ) }`, -]; - -// Example Blocks. -const MOCK_BLOCK1 = { - name: BLOCK1_NAME, - title: 'Block Directory Test Block', - description: 'This plugin is useful for the block.', - id: 'block-directory-test-block', - rating: 0, - rating_count: 0, - active_installs: 0, - author_block_rating: 0, - author_block_count: 1, - author: 'No Author', - icon: 'block-default', - assets: [ - 'https://fake_url.com/block.js', // We will mock this. - ], - humanized_updated: '5 months ago', - links: {}, -}; - -const MOCK_INSTALLED_BLOCK_PLUGIN_DETAILS = { - plugin: 'block-directory-test-block', - status: 'active', - name: 'Block Directory', - plugin_uri: '', - author: 'No Author', - author_uri: '', - description: { - raw: 'This plugin is useful for the block.', - rendered: 'This plugin is useful for the block.', - }, - version: '1.0', - network_only: false, - requires_wp: '', - requires_php: '', - text_domain: 'block-directory-test-block', - _links: { - self: [ - { - href: '', - }, - ], - }, -}; - -const MOCK_BLOCK2 = { - ...MOCK_BLOCK1, - name: 'block-directory-test-block/secondary-block', - title: 'Block Directory Test Block - Pt Deux', - id: 'block-directory-test-secondary-block', -}; - -// Block that will be registered. -const block = `( function() { - var registerBlockType = wp.blocks.registerBlockType; - var el = wp.element.createElement; - - registerBlockType( '${ MOCK_BLOCK1.name }', { - title: 'Test Block for Block Directory', - icon: 'hammer', - category: 'text', - attributes: {}, - edit: function( props ) { - return el( 'p', null, 'Test Copy' ); - }, - save: function() { - return null; - }, - } ); -} )();`; - -const MOCK_EMPTY_RESPONSES = [ - { - match: ( request ) => - matchUrl( request.url(), SEARCH_URLS ) && - request.method() === 'GET', - onRequestMatch: createJSONResponse( [] ), - }, -]; - -const MOCK_BLOCKS_RESPONSES = [ - { - // Mock response for search with the block. - match: ( request ) => - matchUrl( request.url(), SEARCH_URLS ) && - request.method() === 'GET', - onRequestMatch: createJSONResponse( [ MOCK_BLOCK1, MOCK_BLOCK2 ] ), - }, - { - // Mock response for block type. - match: ( request ) => matchUrl( request.url(), BLOCK_TYPE_URLS ), - onRequestMatch: createJSONResponse( {} ), - }, - { - // Mock response for install. - match: ( request ) => matchUrl( request.url(), INSTALL_URLS ), - onRequestMatch: createJSONResponse( - MOCK_INSTALLED_BLOCK_PLUGIN_DETAILS - ), - }, - { - // Mock the response for the js asset once it gets injected. - match: ( request ) => request.url().includes( MOCK_BLOCK1.assets[ 0 ] ), - onRequestMatch: createResponse( - Buffer.from( block, 'utf8' ), - 'application/javascript; charset=utf-8' - ), - }, - { - // Mock the post-new page as requested via apiFetch for determining new CSS/JS assets. - match: ( request ) => request.url().includes( '/post-new.php' ), - onRequestMatch: createResponse( - ``, - 'text/html; charset=UTF-8' - ), - }, -]; - -function getResponseObject( obj, contentType ) { - return { - status: 200, - contentType, - body: obj, - }; -} - -function createResponse( mockResponse, contentType ) { - return async ( request ) => - request.respond( getResponseObject( mockResponse, contentType ) ); -} - -const matchUrl = ( reqUrl, urls ) => { - return urls.some( ( el ) => reqUrl.indexOf( el ) >= 0 ); -}; - -describe( 'adding blocks from block directory', () => { - beforeAll( async () => { - await createNewPost(); - } ); - - it( 'Should show an empty state when no plugin is found.', async () => { - // Be super weird so there won't be a matching block installed. - const impossibleBlockName = '@#$@@Dsdsdfw2#$@'; - - // Return an empty list of plugins. - await setUpResponseMocking( MOCK_EMPTY_RESPONSES ); - - // Search for the block via the inserter. - await searchForBlock( impossibleBlockName ); - - const selectorContent = await page.evaluate( - () => - document.querySelector( '.block-editor-inserter__main-area' ) - .innerHTML - ); - expect( selectorContent ).toContain( - 'block-editor-inserter__no-results' - ); - } ); - - it( 'Should be able to add (the first) block.', async () => { - // Setup our mocks. - await setUpResponseMocking( MOCK_BLOCKS_RESPONSES ); - - // Search for the block via the inserter. - await insertBlockDirectoryBlock( MOCK_BLOCK1.title ); - - await page.waitForSelector( `div[data-type="${ MOCK_BLOCK1.name }"]` ); - - // The block will auto select and get added, make sure we see it in the content. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); -} ); diff --git a/test/e2e/specs/editor/plugins/block-directory.spec.js b/test/e2e/specs/editor/plugins/block-directory.spec.js new file mode 100644 index 00000000000000..28700e787e8836 --- /dev/null +++ b/test/e2e/specs/editor/plugins/block-directory.spec.js @@ -0,0 +1,248 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const BLOCK1_NAME = 'block-directory-test-block/main-block'; + +// Urls to mock. +const SEARCH_URLS = [ + '/wp/v2/block-directory/search', + `rest_route=${ encodeURIComponent( '/wp/v2/block-directory/search' ) }`, +]; + +const BLOCK_TYPE_URLS = [ + `/wp/v2/block-types/${ BLOCK1_NAME }`, + `rest_route=${ encodeURIComponent( + `/wp/v2/block-types/${ BLOCK1_NAME }` + ) }`, +]; + +const INSTALL_URLS = [ + '/wp/v2/plugins', + `rest_route=${ encodeURIComponent( '/wp/v2/plugins' ) }`, +]; + +// Example Blocks. +const MOCK_BLOCK1 = { + name: BLOCK1_NAME, + title: 'Block Directory Test Block', + description: 'This plugin is useful for the block.', + id: 'block-directory-test-block', + rating: 0, + rating_count: 0, + active_installs: 0, + author_block_rating: 0, + author_block_count: 1, + author: 'No Author', + icon: 'block-default', + assets: [ + 'https://fake_url.com/block.js', // We will mock this. + ], + humanized_updated: '5 months ago', + links: {}, +}; + +const MOCK_INSTALLED_BLOCK_PLUGIN_DETAILS = { + plugin: 'block-directory-test-block', + status: 'active', + name: 'Block Directory', + plugin_uri: '', + author: 'No Author', + author_uri: '', + description: { + raw: 'This plugin is useful for the block.', + rendered: 'This plugin is useful for the block.', + }, + version: '1.0', + network_only: false, + requires_wp: '', + requires_php: '', + text_domain: 'block-directory-test-block', + _links: { + self: [ + { + href: '', + }, + ], + }, +}; + +const MOCK_BLOCK2 = { + ...MOCK_BLOCK1, + name: 'block-directory-test-block/secondary-block', + title: 'Block Directory Test Block - Pt Deux', + id: 'block-directory-test-secondary-block', +}; + +// Block that will be registered. +const block = `( function() { + var registerBlockType = wp.blocks.registerBlockType; + var el = wp.element.createElement; + + registerBlockType( '${ MOCK_BLOCK1.name }', { + title: 'Block Directory Test Block', + icon: 'hammer', + category: 'text', + attributes: {}, + edit: function( props ) { + return el( 'p', null, 'Test Copy' ); + }, + save: function() { + return null; + }, + } ); +} )();`; + +function matchUrl( requestUrl, urls ) { + return urls.some( ( url ) => requestUrl.href.includes( url ) ); +} + +test.describe( 'Block Directory', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'Should show an empty state when no plugin is found', async ( { + page, + } ) => { + // Return an empty list of plugins. + await page.route( + ( url ) => matchUrl( url, SEARCH_URLS ), + async ( route, request ) => { + if ( request.method() === 'GET' ) { + await route.fulfill( { + json: [], + } ); + } else { + await route.continue(); + } + } + ); + + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + /** + * Search for the block via the inserter. + * Be super weird so there won't be a matching block installed. + */ + await blockLibrary + .getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ) + .fill( '@$@@Dsdsdfw2$@' ); + + /** + * The inserter can flash "No results found" before requesting results from the directory, + * fulfilling the first assertion. + * Waiting for these two elements ensures that the request was dispatched. + */ + await expect( blockLibrary ).toContainText( 'No results found.' ); + await expect( blockLibrary ).toContainText( + 'Interested in creating your own block?' + ); + } ); + + test( 'Should be able to add (the first) block', async ( { page } ) => { + // Mock response for search with the block. + await page.route( + ( url ) => matchUrl( url, SEARCH_URLS ), + async ( route, request ) => { + if ( request.method() === 'GET' ) { + await route.fulfill( { + json: [ MOCK_BLOCK1, MOCK_BLOCK2 ], + } ); + } else { + await route.continue(); + } + } + ); + + // Mock response for block type. + await page.route( + ( url ) => matchUrl( url, BLOCK_TYPE_URLS ), + async ( route ) => { + await route.fulfill( { + json: {}, + } ); + } + ); + + // Mock response for install. + await page.route( + ( url ) => matchUrl( url, INSTALL_URLS ), + async ( route ) => { + await route.fulfill( { + json: MOCK_INSTALLED_BLOCK_PLUGIN_DETAILS, + } ); + } + ); + + // Mock the response for the js asset once it gets injected. + await page.route( + ( url ) => url.href.includes( MOCK_BLOCK1.assets[ 0 ] ), + async ( route ) => { + await route.fulfill( { + contentType: 'application/javascript; charset=utf-8', + body: Buffer.from( block, 'utf8' ), + } ); + } + ); + + // Mock the post-new page as requested via apiFetch for determining new CSS/JS assets. + await page.route( + ( url ) => url.href.includes( '/post-new.php' ), + async ( route ) => { + await route.fulfill( { + contentType: 'text/html; charset=UTF-8', + body: ``, + } ); + } + ); + + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + const downloadableBlock = page + .getByRole( 'listbox', { + name: 'Blocks available for install', + } ) + .getByRole( 'option', { + name: `Install ${ MOCK_BLOCK1.title }.`, + exact: true, + } ); + + await blockLibrary + .getByRole( 'searchbox', { + name: 'Search for blocks and patterns', + } ) + .fill( MOCK_BLOCK1.title ); + + await expect( downloadableBlock ).toBeVisible(); + + // Install the block. + await downloadableBlock.click(); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: MOCK_BLOCK1.title } ) + .waitFor(); + + await expect( + page.getByRole( 'document', { + name: `Block: ${ MOCK_BLOCK1.title }`, + } ) + ).toBeVisible(); + } ); +} ); From 5d064a514d2224721049bc98177f570e1b09c3f4 Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Wed, 29 Nov 2023 08:53:38 +0200 Subject: [PATCH 15/23] Dataviews: Grid layout refinements (#56441) * Dataviews: Grid layout refinements * grid compact item actions * Revert tooltips and update styles * temp fix to not render empty values in grid * linting issues * do not render description in grid view if empty * add theme's background color in previews empty space --------- Co-authored-by: James Koster --- .../src/components/dataviews/dataviews.js | 14 ++- .../src/components/dataviews/item-actions.js | 9 +- .../src/components/dataviews/style.scss | 63 +++++++++++-- .../src/components/dataviews/view-grid.js | 91 +++++++++++-------- .../src/components/page-pages/index.js | 1 + .../page-templates/dataviews-templates.js | 23 +++-- .../src/components/page-templates/style.scss | 7 +- 7 files changed, 144 insertions(+), 64 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/edit-site/src/components/dataviews/dataviews.js index 78d0ea83abb8ee..56a9cfd7c6ae38 100644 --- a/packages/edit-site/src/components/dataviews/dataviews.js +++ b/packages/edit-site/src/components/dataviews/dataviews.js @@ -71,14 +71,12 @@ export default function DataViews( { onChangeView={ onChangeView } /> - - - + + { !! primaryActions.length && primaryActions.map( ( action ) => { if ( !! action.RenderModal ) { diff --git a/packages/edit-site/src/components/dataviews/style.scss b/packages/edit-site/src/components/dataviews/style.scss index 2d403caa6d4e06..8460c95ef4316a 100644 --- a/packages/edit-site/src/components/dataviews/style.scss +++ b/packages/edit-site/src/components/dataviews/style.scss @@ -65,13 +65,64 @@ } } -.dataviews-view-grid__media { - width: 100%; - min-height: 200px; +.dataviews-grid-view { + margin-bottom: $grid-unit-30; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + + @include break-xlarge() { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency + } + + @include break-huge() { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency + } + + .dataviews-view-grid__card { + h3 { // Todo: A better way to target this + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .dataviews-view-grid__media { + width: 100%; + min-height: 200px; + aspect-ratio: 1/1; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: $radius-block-ui * 2; + overflow: hidden; - > * { - max-width: 100%; - object-fit: cover; + > * { + object-fit: cover; + width: 100%; + height: 100%; + } + } + + .dataviews-view-grid__title { + min-height: $grid-unit-30; + + a { + color: $gray-900; + text-decoration: none; + font-weight: 500; + } + } + + .dataviews-view-grid__fields { + position: relative; + font-size: 12px; + line-height: 16px; + + .dataviews-view-grid__field { + .dataviews-view-grid__field-header { + color: $gray-700; + } + .dataviews-view-grid__field-value { + color: $gray-900; + } + } } } diff --git a/packages/edit-site/src/components/dataviews/view-grid.js b/packages/edit-site/src/components/dataviews/view-grid.js index fd74d4f401a966..597f3b13bd3091 100644 --- a/packages/edit-site/src/components/dataviews/view-grid.js +++ b/packages/edit-site/src/components/dataviews/view-grid.js @@ -5,8 +5,6 @@ import { __experimentalGrid as Grid, __experimentalHStack as HStack, __experimentalVStack as VStack, - FlexBlock, - Placeholder, } from '@wordpress/components'; import { useAsyncList } from '@wordpress/compose'; @@ -19,10 +17,15 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { const mediaField = fields.find( ( field ) => field.id === view.layout.mediaField ); + const primaryField = fields.find( + ( field ) => field.id === view.layout.primaryField + ); const visibleFields = fields.filter( ( field ) => ! view.hiddenFields.includes( field.id ) && - field.id !== view.layout.mediaField + ! [ view.layout.mediaField, view.layout.primaryField ].includes( + field.id + ) ); const shownData = useAsyncList( data, { step: 3 } ); return ( @@ -32,42 +35,56 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { alignment="top" className="dataviews-grid-view" > - { shownData.map( ( item, index ) => { - return ( - -
      - { mediaField?.render( { item, view } ) || ( - - ) } -
      - - - - - { visibleFields.map( ( field ) => ( -
      - { field.render( { item, view } ) } -
      - ) ) } + { shownData.map( ( item, index ) => ( + +
      + { mediaField?.render( { item, view } ) } +
      + + { primaryField?.render( { item, view } ) } + + + + { visibleFields.map( ( field ) => { + const renderedValue = field.render( { + item, + view, + } ); + if ( ! renderedValue ) { + return null; + } + return ( + +
      + { field.header } +
      +
      + { field.render( { item, view } ) } +
      -
      - - - -
      + ); + } ) }
      - ); - } ) } + + ) ) } ); } diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 5e42ce70120478..2d3a4c659f504e 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -39,6 +39,7 @@ const defaultConfigPerViewType = { list: {}, grid: { mediaField: 'featured-image', + primaryField: 'title', }, }; diff --git a/packages/edit-site/src/components/page-templates/dataviews-templates.js b/packages/edit-site/src/components/page-templates/dataviews-templates.js index c0999afa7ee3ff..7dab7192779c89 100644 --- a/packages/edit-site/src/components/page-templates/dataviews-templates.js +++ b/packages/edit-site/src/components/page-templates/dataviews-templates.js @@ -12,6 +12,7 @@ import { __experimentalText as Text, __experimentalHStack as HStack, __experimentalVStack as VStack, + VisuallyHidden, } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; import { useState, useMemo, useCallback } from '@wordpress/element'; @@ -40,7 +41,9 @@ import { import usePatternSettings from '../page-patterns/use-pattern-settings'; import { unlock } from '../../lock-unlock'; -const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); +const { ExperimentalBlockEditorProvider, useGlobalStyle } = unlock( + blockEditorPrivateApis +); const EMPTY_ARRAY = []; @@ -48,6 +51,7 @@ const defaultConfigPerViewType = { list: {}, grid: { mediaField: 'preview', + primaryField: 'title', }, }; @@ -114,6 +118,7 @@ function AuthorField( { item } ) { function TemplatePreview( { content, viewType } ) { const settings = usePatternSettings(); + const [ backgroundColor = 'white' ] = useGlobalStyle( 'color.background' ); const blocks = useMemo( () => { return parse( content ); }, [ content ] ); @@ -131,6 +136,7 @@ function TemplatePreview( { content, viewType } ) {
      @@ -189,12 +195,17 @@ export default function DataviewsTemplates() { id: 'description', getValue: ( { item } ) => item.description, render: ( { item } ) => { - return ( - item.description && ( - - { decodeEntities( item.description ) } + return item.description ? ( + decodeEntities( item.description ) + ) : ( + <> + - ) + + { __( 'No description.' ) } + + ); }, maxWidth: 200, diff --git a/packages/edit-site/src/components/page-templates/style.scss b/packages/edit-site/src/components/page-templates/style.scss index 8af0097b8e2ec4..ed4484550b48d5 100644 --- a/packages/edit-site/src/components/page-templates/style.scss +++ b/packages/edit-site/src/components/page-templates/style.scss @@ -1,9 +1,4 @@ .page-templates-preview-field { - .block-editor-block-preview__container { - border: 1px solid $gray-300; - border-radius: $radius-block-ui; - } - &.is-viewtype-list { .block-editor-block-preview__container { height: 120px; @@ -12,7 +7,7 @@ &.is-viewtype-grid { .block-editor-block-preview__container { - height: 320px; + height: auto; } } } From 702f340dcf91e3e6b1f35a4078981490138fc8e9 Mon Sep 17 00:00:00 2001 From: Ella <4710635+ellatrix@users.noreply.github.com> Date: Wed, 29 Nov 2023 08:29:25 +0100 Subject: [PATCH 16/23] Performance: avoid calling postcss when not needed (#56601) --- packages/block-editor/src/utils/transform-styles/index.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/block-editor/src/utils/transform-styles/index.js b/packages/block-editor/src/utils/transform-styles/index.js index 4da6f6ce237950..742e3a9becaef3 100644 --- a/packages/block-editor/src/utils/transform-styles/index.js +++ b/packages/block-editor/src/utils/transform-styles/index.js @@ -19,6 +19,14 @@ import rebaseUrl from 'postcss-urlrebase'; */ const transformStyles = ( styles, wrapperSelector = '' ) => { return styles.map( ( { css, ignoredSelectors = [], baseURL } ) => { + // When there is no wrapper selector or base URL, there is no need + // to transform the CSS. This is most cases because in the default + // iframed editor, no wrapping is needed, and not many styles + // provide a base URL. + if ( ! wrapperSelector && ! baseURL ) { + return css; + } + try { return postcss( [ From 94848902d043e74e63d093301e35ef3ede51e291 Mon Sep 17 00:00:00 2001 From: JuanMa Date: Wed, 29 Nov 2023 08:12:37 +0000 Subject: [PATCH 17/23] Docs: Fundamentals of Block Development - block.json (#56435) * Add block.json file * Add block.json file for block registration * Refactor block.json for improved performance and development * Clarification over groups of properties for block.json * Update block.json with file paths and enable UI panels * Better organization of subsections * reorganized content of the page and added examples * Update block-json.md with attributes diagram * Update block.json with improved descriptions and formatting * Remove unnecessary code related to `supports` in block-json.md * Update docs/getting-started/fundamentals-block-development/block-json.md Co-authored-by: Ryan Welcher * Update attributes passed to React components and save function in block-json.md * Delete block-json.md file * Add block.json file to TOC and index --------- Co-authored-by: Ryan Welcher --- docs/getting-started/fundamentals/README.md | 1 + .../fundamentals/block-json.md | 120 ++++++++++++++++++ docs/manifest.json | 6 + docs/toc.json | 3 + 4 files changed, 130 insertions(+) create mode 100644 docs/getting-started/fundamentals/block-json.md diff --git a/docs/getting-started/fundamentals/README.md b/docs/getting-started/fundamentals/README.md index fab96d51f7397e..6917723f9a33c1 100644 --- a/docs/getting-started/fundamentals/README.md +++ b/docs/getting-started/fundamentals/README.md @@ -5,5 +5,6 @@ This section provides an introduction to the most important concepts in Block De In this section, you will learn: 1. [**File structure of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/file-structure-of-a-block) - The purpose of each one of the types of files available for a block, the relationships between them, and their role in the output of the block. +1. [**`block.json`**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/block-json) - How a block is defined using its `block.json` metadata and some relevant properties of this file. 1. [**Registration of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How a block is registered in both the server and the client. 1. [**Javascript in the Block Editor**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/javascript-in-the-block-editor) - How to work with Javascript for the Block Editor. \ No newline at end of file diff --git a/docs/getting-started/fundamentals/block-json.md b/docs/getting-started/fundamentals/block-json.md new file mode 100644 index 00000000000000..4589787036e25a --- /dev/null +++ b/docs/getting-started/fundamentals/block-json.md @@ -0,0 +1,120 @@ +# `block.json` + +The `block.json` file simplifies the processs of defining and registering a block by using the same block's definition in JSON format to register the block in both the server and the client. + +[![Open block.json diagram in excalidraw](https://developer.wordpress.org/files/2023/11/block-json.png)](https://excalidraw.com/#json=v1GrIkGsYGKv8P14irBy6,Yy0vl8q7DTTL2VsH5Ww27A "Open block.json diagram in excalidraw") + +
      +Click here to see a full block example and check its block.json +
      + +Besides simplifying a block's registration, using a `block.json` has [several benefits](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#benefits-using-the-metadata-file), including improved performance and development. + +At [**Metadata in block.json**](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#benefits-using-the-metadata-file) you can find a detailed explanation of all the properties you can set in a `block.json` for a block. With these properties you can define things such as: + +- Basic metadata of the block +- Files for the block's behavior, style, or output +- Data Storage in the Block +- Setting UI panels for the block + +## Basic metadata of the block + +Through properties of the `block.json`, we can define how the block will be uniquely identified, how it can be found, and the info displayed for the block in the Block Editor. Some of these properties are: + +- `apiVersion`: the version of [the API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/) used by the block (current version is 2). +- `name`: a unique identifier for a block, including a namespace. +- `title`: a display title for a block. +- `category`: a block category for the block in the Inserter panel. +- `icon`: a [Dashicon](https://developer.wordpress.org/resource/dashicons) slug or a custom SVG icon. +- `description`: a short description visible in the block inspector. +- `keywords`: to locate the block in the inserter. +- `textdomain`: the plugin text-domain (important for things such as translations). + +## Files for the block's behavior, output, or style + +The `editorScript` and `editorStyle` properties allow defining Javascript and CSS files to be enqueued and loaded **only in the editor**. + +The `script` and `style` properties allow the definition of Javascript and CSS files to be enqueued and loaded **in both the editor and the front end**. + +The `viewScript` property allow us to define the Javascript file or files to be enqueued and loaded **only in the front end**. + +All these properties (`editorScript`, `editorStyle`, `script` `style`,`viewScript`) accept as a value a path for the file, a handle registered with `wp_register_script` or `wp_register_style`, or an array with a mix of both. Paths values in `block.json` are prefixed with `file:`. + +The `render` property ([introduced on WordPress 6.1](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/)) sets the path of a `.php` template file that will render the markup returned to the front end. This only method will be used to return the markup for the block on request only if `$render_callback` function has not been passed to the `register_block_type` function. + +## Data Storage in the Block with `attributes` + +The [`attributes` property](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/) allows a block to declare "variables" that store data or content for the block. + +_Example: Attributes as defined in block.json_ +```json +"attributes": { + "fallbackCurrentYear": { + "type": "string" + }, + "showStartingYear": { + "type": "boolean" + }, + "startingYear": { + "type": "string" + } +}, +``` + +
      +Check the attributes reference page for full info about the Attributes API. +
      + +By default `attributes` are serialized and stored in the block's delimiter but this [can be configured](https://developer.wordpress.org/news/2023/09/understanding-block-attributes/). + +_Example: Atributes stored in the Markup representation of the block_ +```html + + +x +``` + +These attributes are passed to the React component `Edit`(to display in the Block Editor) and the `save` function (to return the markup saved to the DB) of the block, and to any server-side render definition for the block (see `render` prop above). + +The `Edit` component receives exclusively the capability of updating the attributes via the `setAttributes` function. + +_See how the attributes are passed to the [`Edit` component](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/edit.js), [the `save` function](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/save.js) and [the `render.php`](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/render.php) in this [full block example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/copyright-date-block-09aac3) of the code above_ + +
      +Check the attributes reference page for full info about the Attributes API. +
      + +[![Open Attributes diagram in excalidraw](https://developer.wordpress.org/files/2023/11/attributes.png)](https://excalidraw.com/#json=pSgCZy8q9GbH7r0oz2fL1,MFCLd6ddQHqi_UqNp5ZSgg "Open Attributes diagram in excalidraw") + + +## Enable UI settings panels for the block with `supports` + +The `supports` property allows a block to declare support for certain features, enabling users to customize specific settings (like colors or margins) from the Settings Sidebar. + +_Example: Supports as defined in block.json_ + +```json +"supports": { + "color": { + "text": true, + "link": true, + "background": true + } +} +``` + +The use of `supports` generates a set of properties that need to be manually added to the wrapping element of the block so they're properly stored as part of the block data. + +_Example: Supports custom settings stored in the Markup representation of the block_ + +```html + +

      Hello World

      + +``` + +_See the [full block example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/block-supports-6aa4dd) of the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/block-supports-6aa4dd/src/block.json)_ + +
      +Check the supports reference page for full info about the Supports API. +
      diff --git a/docs/manifest.json b/docs/manifest.json index 849f1caf23f6c2..6606d57dd6b65c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -107,6 +107,12 @@ "markdown_source": "../docs/getting-started/fundamentals/file-structure-of-a-block.md", "parent": "fundamentals" }, + { + "title": "`block.json`", + "slug": "block-json", + "markdown_source": "../docs/getting-started/fundamentals/block-json.md", + "parent": "fundamentals" + }, { "title": "Registration of a block", "slug": "registration-of-a-block", diff --git a/docs/toc.json b/docs/toc.json index 834bab31048454..ab69092dda42b0 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -51,6 +51,9 @@ { "docs/getting-started/fundamentals/file-structure-of-a-block.md": [] }, + { + "docs/getting-started/fundamentals/block-json.md": [] + }, { "docs/getting-started/fundamentals/registration-of-a-block.md": [] }, From 6e368d5a3a1744f35a2bae4a971b4de3da732afb Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Wed, 29 Nov 2023 09:24:38 +0000 Subject: [PATCH 18/23] Restore placeholder to Post Title field (#56580) --- packages/editor/src/components/post-title/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/post-title/index.js b/packages/editor/src/components/post-title/index.js index a61bc2f52eb842..0c3dbbf7349a17 100644 --- a/packages/editor/src/components/post-title/index.js +++ b/packages/editor/src/components/post-title/index.js @@ -168,7 +168,7 @@ function PostTitle( _, forwardedRef ) { const { ref: richTextRef } = useRichText( { value: title, onChange, - decodedPlaceholder, + placeholder: decodedPlaceholder, selectionStart: selection.start, selectionEnd: selection.end, onSelectionChange( newStart, newEnd ) { From a2ebf0b3357c12984d1317da5645ad5aaae6b8d9 Mon Sep 17 00:00:00 2001 From: JuanMa Date: Wed, 29 Nov 2023 09:52:24 +0000 Subject: [PATCH 19/23] Docs: Fundamentals of Block Development - The block wrapper (#56596) * Add The Block Wrapper * Refactor block wrapper and add useBlockProps hook * Update block wrapper in the Block Editor * Update block markup and wrappers * Update block markup and wrapper attributes * Update block wrapper attributes and add documentation * Content added * Fix server-side render definition for block wrapper * Refactor block wrapper markup * Update docs/getting-started/fundamentals-block-development/the-block-wrapper.md Co-authored-by: Ryan Welcher * Update docs/getting-started/fundamentals-block-development/the-block-wrapper.md Co-authored-by: Ryan Welcher * Update docs/getting-started/fundamentals-block-development/the-block-wrapper.md Co-authored-by: Ryan Welcher * Update docs/getting-started/fundamentals-block-development/the-block-wrapper.md Co-authored-by: Ryan Welcher * Update docs/getting-started/fundamentals-block-development/the-block-wrapper.md Co-authored-by: Ryan Welcher * Fix save function in block wrapper * Refactor block wrapper markup to add attributes * update folder name * Add block-wrapper.md to toc.json * Add link create-block * fixed link Metadata in block.json --------- Co-authored-by: Ryan Welcher --- docs/getting-started/fundamentals/README.md | 1 + .../fundamentals/block-json.md | 9 +- .../fundamentals/block-wrapper.md | 114 ++++++++++++++++++ .../fundamentals/file-structure-of-a-block.md | 4 +- docs/manifest.json | 8 +- docs/toc.json | 3 + 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 docs/getting-started/fundamentals/block-wrapper.md diff --git a/docs/getting-started/fundamentals/README.md b/docs/getting-started/fundamentals/README.md index 6917723f9a33c1..6367603351c82b 100644 --- a/docs/getting-started/fundamentals/README.md +++ b/docs/getting-started/fundamentals/README.md @@ -7,4 +7,5 @@ In this section, you will learn: 1. [**File structure of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/file-structure-of-a-block) - The purpose of each one of the types of files available for a block, the relationships between them, and their role in the output of the block. 1. [**`block.json`**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/block-json) - How a block is defined using its `block.json` metadata and some relevant properties of this file. 1. [**Registration of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How a block is registered in both the server and the client. +1. [**Block wrapper**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How to set proper attributes to the block's markup wrapper. 1. [**Javascript in the Block Editor**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/javascript-in-the-block-editor) - How to work with Javascript for the Block Editor. \ No newline at end of file diff --git a/docs/getting-started/fundamentals/block-json.md b/docs/getting-started/fundamentals/block-json.md index 4589787036e25a..3d65a8f016914e 100644 --- a/docs/getting-started/fundamentals/block-json.md +++ b/docs/getting-started/fundamentals/block-json.md @@ -1,4 +1,4 @@ -# `block.json` +# block.json The `block.json` file simplifies the processs of defining and registering a block by using the same block's definition in JSON format to register the block in both the server and the client. @@ -10,7 +10,7 @@ Click -Check the attributes reference page for full info about the Attributes API. -
      - By default `attributes` are serialized and stored in the block's delimiter but this [can be configured](https://developer.wordpress.org/news/2023/09/understanding-block-attributes/). _Example: Atributes stored in the Markup representation of the block_ diff --git a/docs/getting-started/fundamentals/block-wrapper.md b/docs/getting-started/fundamentals/block-wrapper.md new file mode 100644 index 00000000000000..b391d758b7e546 --- /dev/null +++ b/docs/getting-started/fundamentals/block-wrapper.md @@ -0,0 +1,114 @@ +# The block wrapper + +Each block's markup is wrapped by a container HTML tag that needs to have the proper attributes to fully work in the Block Editor and to reflect the proper block's style settings when rendered in the Block Editor and the front end. As developers, we have full control over the block's markup, and WordPress provides the tools to add the attributes that need to exist on the wrapper to our block's markup. + +Ensuring proper attributes to the block wrapper is especially important when using custom styling or features like `supports`. + +
      +The use of supports generates a set of properties that need to be manually added to the wrapping element of the block so they're properly stored as part of the block data +
      + +A block can have three sets of markup defined, each one of them with a specific target and purpose: + +- The one for the **Block Editor**, defined through a `edit` React component passed to `registerBlockType` when registering the block in the client. +- The one used to **save the block in the DB**, defined through a `save` function passed to `registerBlockType` when registering the block in the client. + - This markup will be returned to the front end on request if no dynamic render has been defined for the block. +- The one used to **dynamically render the markup of the block** returned to the front end on request, defined through the `render_callback` on `register_block_type` or the `render` PHP file in `block.json` + - If defined, this server-side generated markup will be returned to the front end, ignoring the markup stored in DB. + +For the React component `edit` and the `save` function, the block wrapper element should be a native DOM element (like `
      `) or a React component that forwards any additional props to native DOM elements. Using a or component, for instance, would be invalid. + + +## The Edit component's markup + +The `useBlockProps()` hook available on the `@wordpress/block-editor` allows passing the required attributes for the Block Editor to the `edit` block's outer wrapper. + +Among other things, the `useBlockProps()` hook takes care of including in this wrapper: +- An `id` for the block's markup +- Some accesibility and `data-` attributes +- Classes and inline styles reflecting custom settings, which include by default: + - The `wp-block` class + - A class that contains the name of the block with its namespace + +For example, for the following piece of code of a block's registration in the client... + +```js +const Edit = () =>

      Hello World - Block Editor

      ; + +registerBlockType( ..., { + edit: Edit +} ); +``` +_(see the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/minimal-block-ca6eda/src/index.js) in [an example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/minimal-block-ca6eda))_ + +...the markup of the block in the Block Editor could look like this: +```html +

      Hello World - Block Editor

      +``` + +Any additional classes and attributes for the `Edit` component of the block should be passed as an argument of `useBlockProps` (see [example](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/stylesheets-79a4c3/src/edit.js)). When you add `support` for any feature, they get added to the object returned by the `useBlockProps` hook. + + +## The Save component's markup + +When saving the markup in the DB, it’s important to add the block props returned by `useBlockProps.save()` to the wrapper element of your block. `useBlockProps.save()` ensures that the block class name is rendered properly in addition to any HTML attribute injected by the block supports API. + +For example, for the following piece of code of a block's registration in the client that defines the markup desired for the DB (and returned to the front end by default)... + +```js +const Edit = () =>

      Hello World - Block Editor

      ; +const save = () =>

      Hello World - Frontend

      ; + +registerBlockType( ..., { + edit: Edit, + save, +} ); +``` + +_(see the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/minimal-block-ca6eda/src/index.js) in [an example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/minimal-block-ca6eda))_ + + +...the markup of the block in the front end could look like this: +```html +

      Hello World – Frontend

      +``` + +Any additional classes and attributes for the `save` function of the block should be passed as an argument of `useBlockProps.save()` (see [example](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/stylesheets-79a4c3/src/save.js)). + +When you add `support` for any feature, the proper classes get added to the object returned by the `useBlockProps.save()` hook. + +```html +

      Hello World

      +``` + +_(check the [example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/block-supports-6aa4dd) that generated the HTML above in the front end)_ + +## The server-side render markup + +Any markup in the server-side render definition for the block can use the [`get_block_wrapper_attributes()`](https://developer.wordpress.org/reference/functions/get_block_wrapper_attributes/) to generate the string of attributes required to reflect the block settings. function (see [example](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/render.php#L31)). + +```php +

      > + +

      +``` \ No newline at end of file diff --git a/docs/getting-started/fundamentals/file-structure-of-a-block.md b/docs/getting-started/fundamentals/file-structure-of-a-block.md index e38470ee306709..130483ae5af70f 100644 --- a/docs/getting-started/fundamentals/file-structure-of-a-block.md +++ b/docs/getting-started/fundamentals/file-structure-of-a-block.md @@ -1,8 +1,8 @@ # File structure of a block -It is recommended to **register blocks within plugins** to ensure they stay available when a theme gets switched. With the `create-block` tool you can quickly scaffold the structure of the files required to create a plugin that registers a block. +It is recommended to **register blocks within plugins** to ensure they stay available when a theme gets switched. With the [`create-block` tool](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-create-block/) you can quickly scaffold the structure of the files required to create a plugin that registers a block. -The files generated by this tool are a good reference of the files that can be involved in the definition and registration of a block. +The files generated by `create-block` are a good reference of the files that can be involved in the definition and registration of a block. [![Open File Structure of a Block Diagram in excalidraw](https://developer.wordpress.org/files/2023/11/file-structure-block.png)](https://excalidraw.com/#json=YYpeR-kY1ZMhFKVZxGhMi,mVZewfwNAh_oL-7bj4gmdw "Open File Structure of a Block Diagram in excalidraw") diff --git a/docs/manifest.json b/docs/manifest.json index 6606d57dd6b65c..3ab4cefb2b533c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -108,7 +108,7 @@ "parent": "fundamentals" }, { - "title": "`block.json`", + "title": "block.json", "slug": "block-json", "markdown_source": "../docs/getting-started/fundamentals/block-json.md", "parent": "fundamentals" @@ -119,6 +119,12 @@ "markdown_source": "../docs/getting-started/fundamentals/registration-of-a-block.md", "parent": "fundamentals" }, + { + "title": "The block wrapper", + "slug": "block-wrapper", + "markdown_source": "../docs/getting-started/fundamentals/block-wrapper.md", + "parent": "fundamentals" + }, { "title": "Working with Javascript for the Block Editor", "slug": "javascript-in-the-block-editor", diff --git a/docs/toc.json b/docs/toc.json index ab69092dda42b0..91017ce69643c3 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -57,6 +57,9 @@ { "docs/getting-started/fundamentals/registration-of-a-block.md": [] }, + { + "docs/getting-started/fundamentals/block-wrapper.md": [] + }, { "docs/getting-started/fundamentals/javascript-in-the-block-editor.md": [] } From 31c296ecd40545b6cabaa55014520b8b320da86b Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 29 Nov 2023 11:50:29 +0100 Subject: [PATCH 20/23] Add missing changelog entry (#56611) --- packages/interactivity/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 089280cee21448..8130265395fa30 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Breaking Change + +- Implement the new `store()` API as specified in the [proposal](https://github.com/WordPress/gutenberg/discussions/53586). ([#55459](https://github.com/WordPress/gutenberg/pull/55459)) + ## 2.7.0 (2023-11-16) ## 2.6.0 (2023-11-02) From 90cd8dbeff74522452b37474c1ad91ed7ce5265e Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 29 Nov 2023 14:56:01 +0400 Subject: [PATCH 21/23] Migrate 'block icons' e2e tests to Playwright (#56610) * Migrate 'block icons' e2e tests to Playwright Co-authored-by: mimi * Remove old test file --------- Co-authored-by: mimi --- .../e2e-tests/plugins/block-icons/index.js | 2 +- .../specs/editor/plugins/block-icons.test.js | 170 ------------- .../specs/editor/plugins/block-icons.spec.js | 228 ++++++++++++++++++ 3 files changed, 229 insertions(+), 171 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/plugins/block-icons.test.js create mode 100644 test/e2e/specs/editor/plugins/block-icons.spec.js diff --git a/packages/e2e-tests/plugins/block-icons/index.js b/packages/e2e-tests/plugins/block-icons/index.js index feb54e794598b7..5525c15179ad37 100644 --- a/packages/e2e-tests/plugins/block-icons/index.js +++ b/packages/e2e-tests/plugins/block-icons/index.js @@ -55,7 +55,7 @@ } ); registerBlockType( 'test/test-dash-icon', { - title: 'TestDashIcon', + title: 'TestSimpleDashIcon', icon: 'cart', category: 'text', diff --git a/packages/e2e-tests/specs/editor/plugins/block-icons.test.js b/packages/e2e-tests/specs/editor/plugins/block-icons.test.js deleted file mode 100644 index d70cf0e615753f..00000000000000 --- a/packages/e2e-tests/specs/editor/plugins/block-icons.test.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - insertBlock, - pressKeyWithModifier, - searchForBlock, - openDocumentSettingsSidebar, -} from '@wordpress/e2e-test-utils'; - -const INSERTER_BUTTON_SELECTOR = - '.block-editor-inserter__main-area .block-editor-block-types-list__item'; -const INSERTER_ICON_WRAPPER_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .block-editor-block-types-list__item-icon`; -const INSERTER_ICON_SELECTOR = `${ INSERTER_BUTTON_SELECTOR } .block-editor-block-icon`; -const INSPECTOR_ICON_SELECTOR = '.edit-post-sidebar .block-editor-block-icon'; - -async function getInnerHTML( selector ) { - return await page.$eval( selector, ( element ) => element.innerHTML ); -} - -async function getBackgroundColor( selector ) { - return await page.$eval( selector, ( element ) => { - return window.getComputedStyle( element ).backgroundColor; - } ); -} - -async function getColor( selector ) { - return await page.$eval( selector, ( element ) => { - return window.getComputedStyle( element ).color; - } ); -} - -async function getFirstInserterIcon() { - return await getInnerHTML( INSERTER_ICON_SELECTOR ); -} - -async function selectFirstBlock() { - await pressKeyWithModifier( 'access', 'o' ); - const navButtons = await page.$$( - '.block-editor-list-view-block-select-button' - ); - await navButtons[ 0 ].click(); -} - -describe( 'Correctly Renders Block Icons on Inserter and Inspector', () => { - const dashIconRegex = /.*?<\/span>/; - const circleString = - ''; - const svgIcon = new RegExp( - `${ circleString }` - ); - - const validateSvgIcon = ( iconHtml ) => { - expect( iconHtml ).toMatch( svgIcon ); - }; - - const validateDashIcon = ( iconHtml ) => { - expect( iconHtml ).toMatch( dashIconRegex ); - }; - - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-block-icons' ); - } ); - - beforeEach( async () => { - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( 'gutenberg-test-block-icons' ); - } ); - - function testIconsOfBlock( blockName, blockTitle, validateIcon ) { - it( 'Renders correctly the icon in the inserter', async () => { - await searchForBlock( blockTitle ); - validateIcon( await getFirstInserterIcon() ); - } ); - - it( 'Can insert the block', async () => { - await insertBlock( blockTitle ); - expect( - await getInnerHTML( - `[data-type="${ blockName }"] [data-type="core/paragraph"]` - ) - ).toEqual( blockTitle ); - } ); - - it( 'Renders correctly the icon on the inspector', async () => { - await insertBlock( blockTitle ); - await openDocumentSettingsSidebar(); - await selectFirstBlock(); - validateIcon( await getInnerHTML( INSPECTOR_ICON_SELECTOR ) ); - } ); - } - - describe( 'Block with svg icon', () => { - const blockName = 'test/test-single-svg-icon'; - const blockTitle = 'TestSimpleSvgIcon'; - testIconsOfBlock( blockName, blockTitle, validateSvgIcon ); - } ); - - describe( 'Block with dash icon', () => { - const blockName = 'test/test-dash-icon'; - const blockTitle = 'TestDashIcon'; - testIconsOfBlock( blockName, blockTitle, validateDashIcon ); - } ); - - describe( 'Block with function icon', () => { - const blockName = 'test/test-function-icon'; - const blockTitle = 'TestFunctionIcon'; - testIconsOfBlock( blockName, blockTitle, validateSvgIcon ); - } ); - - describe( 'Block with dash icon and background and foreground colors', () => { - const blockTitle = 'TestDashIconColors'; - it( 'Renders the icon in the inserter with the correct colors', async () => { - await searchForBlock( blockTitle ); - validateDashIcon( await getFirstInserterIcon() ); - expect( - await getBackgroundColor( INSERTER_ICON_WRAPPER_SELECTOR ) - ).toEqual( 'rgb(1, 0, 0)' ); - expect( await getColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( - 'rgb(254, 0, 0)' - ); - } ); - - it( 'Renders the icon in the inspector with the correct colors', async () => { - await insertBlock( blockTitle ); - await openDocumentSettingsSidebar(); - await selectFirstBlock(); - validateDashIcon( await getInnerHTML( INSPECTOR_ICON_SELECTOR ) ); - expect( - await getBackgroundColor( INSPECTOR_ICON_SELECTOR ) - ).toEqual( 'rgb(1, 0, 0)' ); - expect( await getColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( - 'rgb(254, 0, 0)' - ); - } ); - } ); - - describe( 'Block with svg icon and background color', () => { - const blockTitle = 'TestSvgIconBackground'; - it( 'Renders the icon in the inserter with the correct background color and an automatically compute readable foreground color', async () => { - await searchForBlock( blockTitle ); - validateSvgIcon( await getFirstInserterIcon() ); - expect( - await getBackgroundColor( INSERTER_ICON_WRAPPER_SELECTOR ) - ).toEqual( 'rgb(1, 0, 0)' ); - expect( await getColor( INSERTER_ICON_WRAPPER_SELECTOR ) ).toEqual( - 'rgb(248, 249, 249)' - ); - } ); - - it( 'Renders correctly the icon on the inspector', async () => { - await insertBlock( blockTitle ); - await openDocumentSettingsSidebar(); - await selectFirstBlock(); - validateSvgIcon( await getInnerHTML( INSPECTOR_ICON_SELECTOR ) ); - expect( - await getBackgroundColor( INSPECTOR_ICON_SELECTOR ) - ).toEqual( 'rgb(1, 0, 0)' ); - expect( await getColor( INSPECTOR_ICON_SELECTOR ) ).toEqual( - 'rgb(248, 249, 249)' - ); - } ); - } ); -} ); diff --git a/test/e2e/specs/editor/plugins/block-icons.spec.js b/test/e2e/specs/editor/plugins/block-icons.spec.js new file mode 100644 index 00000000000000..0418f4200afc05 --- /dev/null +++ b/test/e2e/specs/editor/plugins/block-icons.spec.js @@ -0,0 +1,228 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const dashIconRegex = /.*?<\/span>/; +const circleString = + ''; +const svgIcon = new RegExp( + `${ circleString }` +); + +test.describe( 'Block Icons', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( 'gutenberg-test-block-icons' ); + } ); + + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( 'gutenberg-test-block-icons' ); + } ); + + test( 'Block with svg icon', async ( { editor, page } ) => { + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockLibrary.getByRole( 'searchbox' ).fill( 'TestSimpleSvgIcon' ); + + const blockOption = blockLibrary.getByRole( 'option', { + name: 'TestSimpleSvgIcon', + } ); + const blockIcon = blockOption.locator( '.block-editor-block-icon' ); + + // Renders correctly the icon in the inserter. + await expect.poll( () => blockIcon.innerHTML() ).toMatch( svgIcon ); + + // Can insert the block. + await blockOption.click(); + await expect( + page.getByRole( 'document', { name: 'Block: TestSimpleSvgIcon' } ) + ).toBeVisible(); + + // Renders correctly the icon on the inspector. + await editor.openDocumentSettingsSidebar(); + const inspectorIcon = page + .getByRole( 'region', { name: 'Editor settings' } ) + .locator( '.block-editor-block-icon' ); + await expect.poll( () => inspectorIcon.innerHTML() ).toMatch( svgIcon ); + } ); + + test( 'Block with dash icon', async ( { editor, page } ) => { + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockLibrary + .getByRole( 'searchbox' ) + .fill( 'TestSimpleDashIcon' ); + + const blockOption = blockLibrary.getByRole( 'option', { + name: 'TestSimpleDashIcon', + } ); + const blockIcon = blockOption.locator( '.block-editor-block-icon' ); + + // Renders correctly the icon in the inserter. + await expect + .poll( () => blockIcon.innerHTML() ) + .toMatch( dashIconRegex ); + + // Can insert the block + await blockOption.click(); + await expect( + page.getByRole( 'document', { name: 'Block: TestSimpleDashIcon' } ) + ).toBeVisible(); + + // Renders correctly the icon on the inspector. + await editor.openDocumentSettingsSidebar(); + const inspectorIcon = page + .getByRole( 'region', { name: 'Editor settings' } ) + .locator( '.block-editor-block-icon' ); + await expect + .poll( () => inspectorIcon.innerHTML() ) + .toMatch( dashIconRegex ); + } ); + + test( 'Block with function icon', async ( { editor, page } ) => { + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockLibrary.getByRole( 'searchbox' ).fill( 'TestFunctionIcon' ); + + const blockOption = blockLibrary.getByRole( 'option', { + name: 'TestFunctionIcon', + } ); + const blockIcon = blockOption.locator( '.block-editor-block-icon' ); + + // Renders correctly the icon in the inserter. + await expect.poll( () => blockIcon.innerHTML() ).toMatch( svgIcon ); + + // Can insert the block. + await blockOption.click(); + await expect( + page.getByRole( 'document', { name: 'Block: TestFunctionIcon' } ) + ).toBeVisible(); + + // Renders correctly the icon on the inspector. + await editor.openDocumentSettingsSidebar(); + const inspectorIcon = page + .getByRole( 'region', { name: 'Editor settings' } ) + .locator( '.block-editor-block-icon' ); + await expect.poll( () => inspectorIcon.innerHTML() ).toMatch( svgIcon ); + } ); + + test( 'Block with dash icon and background/foreground colors', async ( { + editor, + page, + } ) => { + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockLibrary + .getByRole( 'searchbox' ) + .fill( 'TestDashIconColors' ); + + const blockOption = blockLibrary.getByRole( 'option', { + name: 'TestDashIconColors', + } ); + const blockIcon = blockOption.locator( '.block-editor-block-icon' ); + + await expect( blockIcon ).toHaveCSS( + 'background-color', + 'rgb(1, 0, 0)' + ); + await expect( blockIcon ).toHaveCSS( 'color', 'rgb(254, 0, 0)' ); + await expect + .poll( () => blockIcon.innerHTML() ) + .toMatch( dashIconRegex ); + + await blockOption.click(); + await editor.openDocumentSettingsSidebar(); + + const inspectorIcon = page + .getByRole( 'region', { name: 'Editor settings' } ) + .locator( '.block-editor-block-icon' ); + + await expect( inspectorIcon ).toHaveCSS( + 'background-color', + 'rgb(1, 0, 0)' + ); + await expect( inspectorIcon ).toHaveCSS( 'color', 'rgb(254, 0, 0)' ); + await expect + .poll( () => inspectorIcon.innerHTML() ) + .toMatch( dashIconRegex ); + } ); + + test( 'Block with svg icon and background should compute a readable foreground color', async ( { + editor, + page, + } ) => { + await page + .getByRole( 'toolbar', { name: 'Document tools' } ) + .getByRole( 'button', { name: 'Toggle block inserter' } ) + .click(); + + const blockLibrary = page.getByRole( 'region', { + name: 'Block Library', + } ); + + await blockLibrary + .getByRole( 'searchbox' ) + .fill( 'TestSvgIconBackground' ); + + const blockOption = blockLibrary.getByRole( 'option', { + name: 'TestSvgIconBackground', + } ); + const blockIcon = blockOption.locator( '.block-editor-block-icon' ); + + await expect( blockIcon ).toHaveCSS( + 'background-color', + 'rgb(1, 0, 0)' + ); + await expect( blockIcon ).toHaveCSS( 'color', 'rgb(248, 249, 249)' ); + await expect.poll( () => blockIcon.innerHTML() ).toMatch( svgIcon ); + + await blockOption.click(); + await editor.openDocumentSettingsSidebar(); + + const inspectorIcon = page + .getByRole( 'region', { name: 'Editor settings' } ) + .locator( '.block-editor-block-icon' ); + + await expect( inspectorIcon ).toHaveCSS( + 'background-color', + 'rgb(1, 0, 0)' + ); + await expect( inspectorIcon ).toHaveCSS( + 'color', + 'rgb(248, 249, 249)' + ); + await expect.poll( () => inspectorIcon.innerHTML() ).toMatch( svgIcon ); + } ); +} ); From 00ccb8b18ea45295bf5edc74f94b7197c768cb96 Mon Sep 17 00:00:00 2001 From: David Arenas Date: Wed, 29 Nov 2023 12:26:28 +0100 Subject: [PATCH 22/23] Create block: Update `interactive-template` to the new `store()` API (#56613) * Update templates to the new `store()` API * Add changelog entry * Use wp_unique_id --------- Co-authored-by: Luis Herranz --- .../CHANGELOG.md | 4 +++ .../block-templates/render.php.mustache | 18 ++++++------- .../block-templates/view.js.mustache | 25 ++++++++----------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md index 46e2a04014e6c0..2ee6b58aabc9ed 100644 --- a/packages/create-block-interactive-template/CHANGELOG.md +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancement + +- Update `view.js` and `render.php` templates to the new `store()` API. [#56613](https://github.com/WordPress/gutenberg/pull/56613) + ## 1.9.0 (2023-11-16) ## 1.8.0 (2023-11-02) diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache index c458473d565e00..efecd748d19ef8 100644 --- a/packages/create-block-interactive-template/block-templates/render.php.mustache +++ b/packages/create-block-interactive-template/block-templates/render.php.mustache @@ -11,26 +11,26 @@ * @see https://github.com/WordPress/gutenberg/blob/trunk/docs/reference-guides/block-api/block-metadata.md#render */ -$unique_id = uniqid( 'p-' ); +$unique_id = wp_unique_id( 'p-' ); ?>
      - data-wp-interactive - data-wp-context='{ "{{namespace}}": { "isOpen": false } }' - data-wp-effect="effects.{{namespace}}.logIsOpen" + data-wp-interactive='{ "namespace": "{{namespace}}" }' + data-wp-context='{ "isOpen": false }' + data-wp-watch="callbacks.logIsOpen" >

      { - context[ '{{namespace}}' ].isOpen = !context[ '{{namespace}}' ].isOpen; - }, + toggle: () => { + const context = getContext(); + context.isOpen = ! context.isOpen; }, }, - effects: { - '{{namespace}}': { - logIsOpen: ( { context } ) => { - // Log the value of `isOpen` each time it changes. - console.log( `Is open: ${ context[ '{{namespace}}' ].isOpen }` ); - }, + callbacks: { + logIsOpen: () => { + const { isOpen } = getContext(); + // Log the value of `isOpen` each time it changes. + console.log( `Is open: ${ isOpen }` ); }, }, } ); -{{/isBasicVariant}} \ No newline at end of file +{{/isBasicVariant}} From f7d2103acca1f984fc5aa5f4fb8424ee6507bfe8 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Wed, 29 Nov 2023 13:45:43 +0000 Subject: [PATCH 23/23] Restore Post Title visual styles in Code View mode (#56582) * Apply styles to match previous visuals * Adjust CSS in response to feedback --- packages/edit-post/src/components/text-editor/style.scss | 4 +++- .../editor/src/components/post-title/post-title-raw.js | 1 + packages/editor/src/components/post-title/style.scss | 7 ++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/edit-post/src/components/text-editor/style.scss b/packages/edit-post/src/components/text-editor/style.scss index 925e88df27180b..c02e983057e6ef 100644 --- a/packages/edit-post/src/components/text-editor/style.scss +++ b/packages/edit-post/src/components/text-editor/style.scss @@ -5,7 +5,8 @@ flex-grow: 1; // Post title. - .editor-post-title { + .editor-post-title:not(.is-raw-text), + .editor-post-title.is-raw-text textarea { max-width: none; line-height: $default-line-height; @@ -14,6 +15,7 @@ font-weight: normal; border: $border-width solid $gray-600; + border-radius: 0; // Same padding as body. padding: $grid-unit-20; diff --git a/packages/editor/src/components/post-title/post-title-raw.js b/packages/editor/src/components/post-title/post-title-raw.js index b6a52e43731926..f59ec40e872e45 100644 --- a/packages/editor/src/components/post-title/post-title-raw.js +++ b/packages/editor/src/components/post-title/post-title-raw.js @@ -73,6 +73,7 @@ function PostTitleRaw( _, forwardedRef ) { hideLabelFromVision={ true } autoComplete="off" dir="auto" + rows={ 1 } __nextHasNoMarginBottom /> ); diff --git a/packages/editor/src/components/post-title/style.scss b/packages/editor/src/components/post-title/style.scss index bf667c39933bdf..98bdfb9a2ebf3a 100644 --- a/packages/editor/src/components/post-title/style.scss +++ b/packages/editor/src/components/post-title/style.scss @@ -1,4 +1,5 @@ -.edit-post-text-editor__body .is-raw-text textarea { - font-size: inherit; - line-height: inherit; +// Raw Text Variant +.edit-post-text-editor__body .editor-post-title.is-raw-text { + margin-bottom: $grid-unit-30; + margin-top: 2px; // space for focus outline to appear. }