From 4155c0071adaa1796b5cf47ac187486913bb9cef Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Mon, 27 Nov 2023 14:22:25 +1100 Subject: [PATCH 01/43] List View: Expand state if a block is dragged to within a collapsed block in the editor canvas (#56493) --- .../use-list-view-expand-selected-item.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/block-editor/src/components/list-view/use-list-view-expand-selected-item.js b/packages/block-editor/src/components/list-view/use-list-view-expand-selected-item.js index 09b5e09e4713a3..f84419dc1db933 100644 --- a/packages/block-editor/src/components/list-view/use-list-view-expand-selected-item.js +++ b/packages/block-editor/src/components/list-view/use-list-view-expand-selected-item.js @@ -27,12 +27,6 @@ export default function useListViewExpandSelectedItem( { [ firstSelectedBlockClientId ] ); - const parentClientIds = - Array.isArray( selectedBlockParentClientIds ) && - selectedBlockParentClientIds.length - ? selectedBlockParentClientIds - : null; - // Expand tree when a block is selected. useEffect( () => { // If the selectedTreeId is the same as the selected block, @@ -42,7 +36,7 @@ export default function useListViewExpandSelectedItem( { } // If the selected block has parents, get the top-level parent. - if ( parentClientIds ) { + if ( selectedBlockParentClientIds?.length ) { // If the selected block has parents, // expand the tree branch. setExpandedState( { @@ -50,7 +44,12 @@ export default function useListViewExpandSelectedItem( { clientIds: selectedBlockParentClientIds, } ); } - }, [ firstSelectedBlockClientId ] ); + }, [ + firstSelectedBlockClientId, + selectedBlockParentClientIds, + selectedTreeId, + setExpandedState, + ] ); return { setSelectedTreeId, From 0246a6b73f74a63e75cc5de9ead99b647cfa2b0a Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:29:48 +1000 Subject: [PATCH 02/43] Design Tools: Fix last ToolsPanelItem styling (#56536) --- packages/components/CHANGELOG.md | 4 ++++ .../src/tools-panel/tools-panel-item/hook.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 0f60b1aaee51ab..89dfdf8cdfc13c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -6,6 +6,10 @@ - `Button`: Add focus rings to focusable disabled buttons ([#56383](https://github.com/WordPress/gutenberg/pull/56383)). +### Bug Fix + +- `ToolsPanelItem`: Use useLayoutEffect to prevent rendering glitch for last panel item styling. ([#56536](https://github.com/WordPress/gutenberg/pull/56536)). + ### Experimental - `Tabs`: Memoize and expose the component context ([#56224](https://github.com/WordPress/gutenberg/pull/56224)). diff --git a/packages/components/src/tools-panel/tools-panel-item/hook.ts b/packages/components/src/tools-panel/tools-panel-item/hook.ts index 23701afdfcfd07..244349b6379eaf 100644 --- a/packages/components/src/tools-panel/tools-panel-item/hook.ts +++ b/packages/components/src/tools-panel/tools-panel-item/hook.ts @@ -2,7 +2,12 @@ * WordPress dependencies */ import { usePrevious } from '@wordpress/compose'; -import { useCallback, useEffect, useMemo } from '@wordpress/element'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, +} from '@wordpress/element'; /** * Internal dependencies @@ -59,7 +64,11 @@ export function useToolsPanelItem( // Registering the panel item allows the panel to include it in its // automatically generated menu and determine its initial checked status. - useEffect( () => { + // + // This is performed in a layout effect to ensure that the panel item + // is registered before it is rendered preventing a rendering glitch. + // See: https://github.com/WordPress/gutenberg/issues/56470 + useLayoutEffect( () => { if ( hasMatchingPanel && previousPanelId !== null ) { registerPanelItem( { hasValue: hasValueCallback, From 4f4386c8af9e37aec9b5ca7dea2c6ea6d77b5760 Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 27 Nov 2023 15:05:37 +1100 Subject: [PATCH 03/43] Style engine: allow CSS var output for fontSize and fontFamily and update docs (#56528) * - Loading style engine files that use utils classes after the utils classes have loaded - Adding CSS var parsing capability to fontSize and fontFamily. The JS package does this, theme.json has these presets, why not the Style Engine? I think it wasn't a deliberate omission. - Updating docs in relation to CSS var names * Apply suggestions from code review Spelling fix Co-authored-by: Andrew Serong <14988353+andrewserong@users.noreply.github.com> * Replacing spacing indents with tabs. Disclaimer: This commit does not necessarily reflect the views or positions of the author in relation to tabs vs spaces. --------- Co-authored-by: Andrew Serong <14988353+andrewserong@users.noreply.github.com> --- lib/load.php | 4 +- .../style-engine/class-wp-style-engine.php | 6 ++ ...ng-the-style-engine-with-block-supports.md | 57 ++++++++++++------- phpunit/style-engine/style-engine-test.php | 15 +++-- 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/lib/load.php b/lib/load.php index 745dddd8ab1076..e2d804befe7d79 100644 --- a/lib/load.php +++ b/lib/load.php @@ -224,12 +224,12 @@ function () { // Copied package PHP files. if ( is_dir( __DIR__ . '/../build/style-engine' ) ) { - require_once __DIR__ . '/../build/style-engine/style-engine-gutenberg.php'; - require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-gutenberg.php'; require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-css-declarations-gutenberg.php'; require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-css-rule-gutenberg.php'; require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-css-rules-store-gutenberg.php'; require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-processor-gutenberg.php'; + require_once __DIR__ . '/../build/style-engine/class-wp-style-engine-gutenberg.php'; + require_once __DIR__ . '/../build/style-engine/style-engine-gutenberg.php'; } // Block supports overrides. diff --git a/packages/style-engine/class-wp-style-engine.php b/packages/style-engine/class-wp-style-engine.php index a8935fc5ef14b2..fdb0083ad9eece 100644 --- a/packages/style-engine/class-wp-style-engine.php +++ b/packages/style-engine/class-wp-style-engine.php @@ -210,6 +210,9 @@ final class WP_Style_Engine { 'property_keys' => array( 'default' => 'font-size', ), + 'css_vars' => array( + 'font-size' => '--wp--preset--font-size--$slug', + ), 'path' => array( 'typography', 'fontSize' ), 'classnames' => array( 'has-$slug-font-size' => 'font-size', @@ -219,6 +222,9 @@ final class WP_Style_Engine { 'property_keys' => array( 'default' => 'font-family', ), + 'css_vars' => array( + 'font-family' => '--wp--preset--font-family--$slug', + ), 'path' => array( 'typography', 'fontFamily' ), 'classnames' => array( 'has-$slug-font-family' => 'font-family', diff --git a/packages/style-engine/docs/using-the-style-engine-with-block-supports.md b/packages/style-engine/docs/using-the-style-engine-with-block-supports.md index 07d91c54a28d6a..42350a17ffcd6d 100644 --- a/packages/style-engine/docs/using-the-style-engine-with-block-supports.md +++ b/packages/style-engine/docs/using-the-style-engine-with-block-supports.md @@ -30,11 +30,11 @@ The global function `wp_style_engine_get_styles` accepts a style object as its f ```php $block_styles = array( - 'spacing' => array( 'padding' => '10px', 'margin' => array( 'top' => '1em') ), - 'typography' => array( 'fontSize' => '2.2rem' ), + 'spacing' => array( 'padding' => '10px', 'margin' => array( 'top' => '1em') ), + 'typography' => array( 'fontSize' => '2.2rem' ), ); $styles = wp_style_engine_get_styles( - $block_styles + $block_styles ); print_r( $styles ); @@ -51,7 +51,7 @@ When [registering a block support](https://developer.wordpress.org/reference/cla If a block has opted into the block support, the values of "class" and "style" will be applied to the block element's "class" and "style" attributes accordingly when rendered in the frontend HTML. Note, this applies only to server-side rendered blocks, for example, the [Site Title block](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/#site-title). -The callback receives `$block_type` and `$block_attributes` as arguments. The `style` attribute within `$block_attributes` only contains the raw style object, if any styles have been set for the block, and not any CSS or classnames to be applied to the block's HTML elements. +The callback receives `$block_type` and `$block_attributes` as arguments. The `style` attribute within `$block_attributes` only contains the raw style object, if any styles have been set for the block, and not any CSS or classnames to be applied to the block's HTML elements. Here is where `wp_style_engine_get_styles` comes in handy: it will generate CSS and, if appropriate, classnames to be added to the "style" and "class" HTML attributes in the final rendered block markup. @@ -100,7 +100,7 @@ Before passing the block style object to the Style Engine, you'll need to take i If a block either: -- has no support for a style, or +- has no support for a style, or - skips serialization of that style it's likely that you'll want to remove those style values from the style object before passing it to the Style Engine with help of two functions: @@ -108,24 +108,24 @@ it's likely that you'll want to remove those style values from the style object - wp_should_skip_block_supports_serialization() - block_has_support() -We can now update the apply callback code above so that we'll only return "style" and "class", where a block has support and it doesn't skip serialization: +We can now update the "apply" callback code above so that we'll only return "style" and "class", where a block has support, and it doesn't skip serialization: ```php function gutenberg_apply_colors_support( $block_type, $block_attributes ) { // The return value. $attributes = array(); - + // Return early if the block skips all serialization for block supports. if ( gutenberg_should_skip_block_supports_serialization( $block_type, 'color' ) ) { return $attributes; } // Checks for support and skip serialization. - $has_text_support = block_has_support( $block_type, array( 'color', 'text' ), false ); + $has_text_support = block_has_support( $block_type, array( 'color', 'text' ), false ); $has_background_support = block_has_support( $block_type, array( 'color', 'background' ), false ); - $skips_serialization_of_color_text = wp_should_skip_block_supports_serialization( $block_type, 'color', 'text' ); - $skips_serialization_of_color_background = wp_should_skip_block_supports_serialization( $block_type, 'color', 'background' ); - + $skips_serialization_of_color_text = wp_should_skip_block_supports_serialization( $block_type, 'color', 'text' ); + $skips_serialization_of_color_background = wp_should_skip_block_supports_serialization( $block_type, 'color', 'background' ); + // Get the color styles from the style object. $block_color_styles = isset( $block_attributes['style']['color'] ) ? $block_attributes['style']['color'] : null; @@ -161,7 +161,7 @@ Styling a block using these presets normally involves adding the selector to the For styles that can have preset values, such as text color and font family, the Style Engine knows how to construct the classnames using the preset slug. -To discern CSS values from preset values however, the Style Engine expects a special format. +To discern CSS values from preset values, the Style Engine expects a special format. Preset values must follow the pattern `var:preset||`. @@ -171,13 +171,16 @@ Example: ```php // Let's say the block attributes styles contain a fontSize preset slug of "small". +// $block_attributes['fontSize'] = 'var:preset|font-size|small'; $preset_font_size = "var:preset|font-size|{$block_attributes['fontSize']}"; // Now let's say the block attributes styles contain a backgroundColor preset slug of "blue". +// $block_attributes['backgroundColor'] = 'var:preset|color|blue'; $preset_background_color = "var:preset|color|{$block_attributes['backgroundColor']}"; $block_styles = array( 'typography' => array( 'fontSize' => $preset_font_size ), - 'color' => array( 'background' => $preset_background_color ) + 'color' => array( 'background' => $preset_background_color ), + 'spacing' => array( 'padding' => '10px', 'margin' => array( 'top' => '1em') ), ); $styles = wp_style_engine_get_styles( @@ -187,19 +190,29 @@ print_r( $styles ); /* array( - 'css' => 'background-color:var(--wp--preset--color--blue);font-size:var(--wp--preset--font-size--small);', - 'classnames' => 'has-background-color has-blue-background-color has-small-font-size', + 'css' => 'background-color:var(--wp--preset--color--blue);padding:10px;margin-top:1em;font-size:var(--wp--preset--font-size--small);', + 'declarations' => array( + 'background-color' => 'var(--wp--preset--color--blue)', + 'padding' => '10px', + 'margin-top' => '1em', + 'font-size' => 'var(--wp--preset--font-size--small)', + ), + 'classnames' => 'has-background has-blue-background-color has-small-font-size', ) */ ``` -If you don't want the Style Engine to output the CSS custom vars as well, which you might not if you're applying both the CSS and classnames to the block element, you can pass `'convert_vars_to_classnames' => true` in the options array. +If you don't want the Style Engine to output the CSS custom vars in the generated CSS string as well, which you might not if you're applying both the CSS rules and classnames to the block element, you can pass `'convert_vars_to_classnames' => true` in the options array. + +This flag means "convert the vars to classnames and don't output the vars to the CSS". The Style Engine will therefore **only** generate the required classnames and omit the CSS custom vars in the CSS. + +Using the above example code, observe the different output when we pass the option: ```php $options = array( - // Whether to skip converting CSS var:? values to var( --wp--preset--* ) values. Default is `false`. - 'convert_vars_to_classnames' => 'true', + 'convert_vars_to_classnames' => true, ); + $styles = wp_style_engine_get_styles( $block_styles, $options @@ -208,8 +221,12 @@ print_r( $styles ); /* array( - 'css' => 'letter-spacing:12px;', // non-preset-based CSS will still be compiled. - 'classnames' => 'has-background-color has-blue-background-color has-small-font-size', + 'css' => 'padding:10px;margin-top:1em;', + 'declarations' => array( + 'padding' => '10px', + 'margin-top' => '1em', + ), + 'classnames' => 'has-background has-blue-background-color has-small-font-size', ) */ ``` diff --git a/phpunit/style-engine/style-engine-test.php b/phpunit/style-engine/style-engine-test.php index 6fb485e9a6f318..d8ded749c640b2 100644 --- a/phpunit/style-engine/style-engine-test.php +++ b/phpunit/style-engine/style-engine-test.php @@ -238,19 +238,26 @@ public function data_wp_style_engine_get_styles() { 'elements_with_css_var_value' => array( 'block_styles' => array( - 'color' => array( + 'color' => array( 'text' => 'var:preset|color|my-little-pony', ), + 'typography' => array( + 'fontSize' => 'var:preset|font-size|cabbage-patch', + 'fontFamily' => 'var:preset|font-family|transformers', + ), ), 'options' => array( 'selector' => '.wp-selector', ), 'expected_output' => array( - 'css' => '.wp-selector{color:var(--wp--preset--color--my-little-pony);}', + 'css' => '.wp-selector{color:var(--wp--preset--color--my-little-pony);font-size:var(--wp--preset--font-size--cabbage-patch);font-family:var(--wp--preset--font-family--transformers);}', 'declarations' => array( - 'color' => 'var(--wp--preset--color--my-little-pony)', + 'color' => 'var(--wp--preset--color--my-little-pony)', + 'font-size' => 'var(--wp--preset--font-size--cabbage-patch)', + 'font-family' => 'var(--wp--preset--font-family--transformers)', + ), - 'classnames' => 'has-text-color has-my-little-pony-color', + 'classnames' => 'has-text-color has-my-little-pony-color has-cabbage-patch-font-size has-transformers-font-family', ), ), From 2d4000f5692498d54ea6cf496bc08f19ddfc9e2d Mon Sep 17 00:00:00 2001 From: Daniel Richards Date: Mon, 27 Nov 2023 13:58:41 +0800 Subject: [PATCH 04/43] Fix FormTokenField suggestions broken scrollbar when `__experimentalExpandOnFocus` is defined (#56426) * Fix FormTokenField suggestions being unscrollable when `__experimentalExpandOnFocus` is defined * Update focus types * Add changelog entry * Type the form token field onFocus event as a React focus event * Shorten to just FocusEvent instead of ReactFocusEvent * Add another CHANGELOG entry for the typing fix --------- Co-authored-by: Glen Davies --- packages/components/CHANGELOG.md | 2 ++ packages/components/src/form-token-field/index.tsx | 14 +++++++++++--- .../src/form-token-field/token-input.tsx | 4 +--- packages/components/src/form-token-field/types.ts | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 89dfdf8cdfc13c..08082f99a00b92 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -9,6 +9,8 @@ ### Bug Fix - `ToolsPanelItem`: Use useLayoutEffect to prevent rendering glitch for last panel item styling. ([#56536](https://github.com/WordPress/gutenberg/pull/56536)). +- `FormTokenField`: Fix broken suggestions scrollbar when the `__experimentalExpandOnFocus` prop is defined ([#56426](https://github.com/WordPress/gutenberg/pull/56426)). +- `FormTokenField`: `onFocus` prop is now typed as a React `FocusEvent` ([#56426](https://github.com/WordPress/gutenberg/pull/56426)). ### Experimental diff --git a/packages/components/src/form-token-field/index.tsx b/packages/components/src/form-token-field/index.tsx index b3a5c5a53d4c0a..bdc3c2a53ae1d0 100644 --- a/packages/components/src/form-token-field/index.tsx +++ b/packages/components/src/form-token-field/index.tsx @@ -2,7 +2,7 @@ * External dependencies */ import classnames from 'classnames'; -import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react'; +import type { KeyboardEvent, MouseEvent, TouchEvent, FocusEvent } from 'react'; /** * WordPress dependencies @@ -162,7 +162,7 @@ export function FormTokenField( props: FormTokenFieldProps ) { } } - function onBlur() { + function onBlur( event: FocusEvent ) { if ( inputHasValidValue() && __experimentalValidateInput( incompleteTokenValue ) @@ -176,7 +176,15 @@ export function FormTokenField( props: FormTokenFieldProps ) { setIncompleteTokenValue( '' ); setInputOffsetFromEnd( 0 ); setIsActive( false ); - setIsExpanded( false ); + + // If `__experimentalExpandOnFocus` is true, don't close the suggestions list when + // the user clicks on it (`tokensAndInput` will be the element that caused the blur). + const shouldKeepSuggestionsExpanded = + ! __experimentalExpandOnFocus || + ( __experimentalExpandOnFocus && + event.relatedTarget === tokensAndInput.current ); + setIsExpanded( shouldKeepSuggestionsExpanded ); + setSelectedSuggestionIndex( -1 ); setSelectedSuggestionScroll( false ); } diff --git a/packages/components/src/form-token-field/token-input.tsx b/packages/components/src/form-token-field/token-input.tsx index a8367695670b20..c2b4983c51cb9d 100644 --- a/packages/components/src/form-token-field/token-input.tsx +++ b/packages/components/src/form-token-field/token-input.tsx @@ -48,9 +48,7 @@ export function UnForwardedTokenInput( onFocus?.( e ); }; - const onBlurHandler: React.FocusEventHandler< HTMLInputElement > = ( - e - ) => { + const onBlurHandler: FocusEventHandler< HTMLInputElement > = ( e ) => { setHasFocus( false ); onBlur?.( e ); }; diff --git a/packages/components/src/form-token-field/types.ts b/packages/components/src/form-token-field/types.ts index e7eabd381a66a6..db4549a7f0779c 100644 --- a/packages/components/src/form-token-field/types.ts +++ b/packages/components/src/form-token-field/types.ts @@ -5,6 +5,7 @@ import type { ComponentPropsWithRef, MouseEventHandler, ReactNode, + FocusEvent, } from 'react'; type Messages = { From 0adb73f074b29f940012d69267594c2bcf083b5e Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 27 Nov 2023 08:39:58 +0100 Subject: [PATCH 05/43] Site Editor: Add a fallback template showing the title and content for the post only mode (#56509) --- .../page-panels/edit-template.js | 49 ++++++++----------- packages/edit-site/src/utils/constants.js | 10 ---- .../editor/src/components/provider/index.js | 13 +++-- 3 files changed, 30 insertions(+), 42 deletions(-) diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js index 58d917baed1f28..a11d119e1cecb7 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js @@ -7,7 +7,6 @@ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; import { check } from '@wordpress/icons'; -import { store as blockEditorStore } from '@wordpress/block-editor'; import { privateApis as editorPrivateApis, store as editorStore, @@ -20,7 +19,6 @@ import { store as editSiteStore } from '../../../store'; import SwapTemplateButton from './swap-template-button'; import ResetDefaultTemplate from './reset-default-template'; import { unlock } from '../../../lock-unlock'; -import { PAGE_CONTENT_BLOCK_TYPES } from '../../../utils/constants'; const { PostPanelRow } = unlock( editorPrivateApis ); @@ -30,22 +28,17 @@ const POPOVER_PROPS = { }; export default function EditTemplate() { - const { hasPostContentBlocks, hasResolved, template, isTemplateHidden } = - useSelect( ( select ) => { + const { hasResolved, template, isTemplateHidden } = useSelect( + ( select ) => { const { getEditedPostContext, getEditedPostType, getEditedPostId } = select( editSiteStore ); const { getRenderingMode } = unlock( select( editorStore ) ); const { getEditedEntityRecord, hasFinishedResolution } = select( coreStore ); - const { __experimentalGetGlobalBlocksByName } = - select( blockEditorStore ); const _context = getEditedPostContext(); const _postType = getEditedPostType(); const queryArgs = [ 'postType', _postType, getEditedPostId() ]; return { - hasPostContentBlocks: !! __experimentalGetGlobalBlocksByName( - Object.keys( PAGE_CONTENT_BLOCK_TYPES ) - ).length, context: _context, hasResolved: hasFinishedResolution( 'getEditedEntityRecord', @@ -55,7 +48,9 @@ export default function EditTemplate() { isTemplateHidden: getRenderingMode() === 'post-only', postType: _postType, }; - }, [] ); + }, + [] + ); const { setRenderingMode } = useDispatch( editorStore ); @@ -90,25 +85,21 @@ export default function EditTemplate() { - { hasPostContentBlocks && ( - - { - setRenderingMode( - isTemplateHidden - ? 'template-locked' - : 'post-only' - ); - } } - > - { __( 'Template preview' ) } - - - ) } + + { + setRenderingMode( + isTemplateHidden + ? 'template-locked' + : 'post-only' + ); + } } + > + { __( 'Template preview' ) } + + ) } diff --git a/packages/edit-site/src/utils/constants.js b/packages/edit-site/src/utils/constants.js index 0b92252935a79f..0aae3e681a16e5 100644 --- a/packages/edit-site/src/utils/constants.js +++ b/packages/edit-site/src/utils/constants.js @@ -38,16 +38,6 @@ export const FOCUSABLE_ENTITIES = [ PATTERN_TYPES.user, ]; -/** - * Block types that are considered to be page content. These are the only blocks - * editable when the page is focused. - */ -export const PAGE_CONTENT_BLOCK_TYPES = { - 'core/post-title': true, - 'core/post-featured-image': true, - 'core/post-content': true, -}; - export const POST_TYPE_LABELS = { [ TEMPLATE_POST_TYPE ]: __( 'Template' ), [ TEMPLATE_PART_POST_TYPE ]: __( 'Template part' ), diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 37128918f1a422..6be4d6d6fe4607 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -125,6 +125,10 @@ function useBlockEditorProps( post, template, mode ) { } if ( mode === 'post-only' ) { + const postContentBlocks = + extractPageContentBlockTypesFromTemplateBlocks( + templateBlocks + ); return [ createBlock( 'core/group', @@ -138,9 +142,12 @@ function useBlockEditorProps( post, template, mode ) { }, }, }, - extractPageContentBlockTypesFromTemplateBlocks( - templateBlocks - ) + postContentBlocks.length + ? postContentBlocks + : [ + createBlock( 'core/post-title' ), + createBlock( 'core/post-content' ), + ] ), ]; } From c86154f838cef387785a948e30a5a7c7cbbae47f Mon Sep 17 00:00:00 2001 From: Nik Tsekouras Date: Mon, 27 Nov 2023 11:25:20 +0200 Subject: [PATCH 06/43] Dataviews: Update item actions in grid view (#56501) * Dataviews: Update item actions in grid view * Use new dropdown * remove separators and icons * add `isCompact` prop instead of viewType --- .../src/components/dataviews/item-actions.js | 137 +++++++++++++----- .../src/components/dataviews/view-grid.js | 1 + 2 files changed, 99 insertions(+), 39 deletions(-) diff --git a/packages/edit-site/src/components/dataviews/item-actions.js b/packages/edit-site/src/components/dataviews/item-actions.js index 2c3373c14bfdf6..c35fe4f77e4dfd 100644 --- a/packages/edit-site/src/components/dataviews/item-actions.js +++ b/packages/edit-site/src/components/dataviews/item-actions.js @@ -2,18 +2,28 @@ * WordPress dependencies */ import { - DropdownMenu, - MenuGroup, - MenuItem, Button, Modal, __experimentalHStack as HStack, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useMemo, useState } from '@wordpress/element'; import { moreVertical } from '@wordpress/icons'; -function PrimaryActionTrigger( { action, onClick } ) { +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { + DropdownMenuV2Ariakit: DropdownMenu, + DropdownMenuGroupV2Ariakit: DropdownMenuGroup, + DropdownMenuItemV2Ariakit: DropdownMenuItem, + DropdownMenuItemLabelV2Ariakit: DropdownMenuItemLabel, +} = unlock( componentsPrivateApis ); + +function ButtonTrigger( { action, onClick } ) { return ( + + + + ); + }, +}; export function usePermanentlyDeletePostAction() { const { createSuccessNotice, createErrorNotice } = diff --git a/packages/edit-site/src/components/dataviews/README.md b/packages/edit-site/src/components/dataviews/README.md index 9c6725a39ead26..20e3fe7ff9165b 100644 --- a/packages/edit-site/src/components/dataviews/README.md +++ b/packages/edit-site/src/components/dataviews/README.md @@ -184,6 +184,6 @@ Array of operations that can be performed upon each record. Each action is an ob - `icon`: icon to show for primary actions. It's required for a primary action, otherwise the action would be considered secondary. - `isEligible`: function, optional. Whether the action can be performed for a given record. If not present, the action is considered to be eligible for all items. It takes the given record as input. - `isDestructive`: boolean, optional. Whether the action can delete data, in which case the UI would communicate it via red color. -- `callback`: function, required. Callback function that takes the record as input and performs the required action. -- `RenderModal`: ReactElement, optional. If an action requires to render contents in a modal, can provide a component which takes as input the record and a `closeModal` function. If this prop is provided, the `callback` property would be ignored. +- `callback`: function, required unless `RenderModal` is provided. Callback function that takes the record as input and performs the required action. +- `RenderModal`: ReactElement, optional. If an action requires that some UI be rendered in a modal, it can provide a component which takes as props the record as `item` and a `closeModal` function. When this prop is provided, the `callback` property is ignored. - `hideModalHeader`: boolean, optional. This property is used in combination with `RenderModal` and controls the visibility of the modal's header. If the action renders a modal and doesn't hide the header, the action's label is going to be used in the modal's header. diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 6f0b8b942ebe36..5e42ce70120478 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -21,7 +21,7 @@ import Link from '../routes/link'; import { DataViews, viewTypeSupportsMap } from '../dataviews'; import { default as DEFAULT_VIEWS } from '../sidebar-dataviews/default-views'; import { - useTrashPostAction, + trashPostAction, usePermanentlyDeletePostAction, useRestorePostAction, postRevisionsAction, @@ -259,7 +259,6 @@ export default function PagePages() { [ authors ] ); - const trashPostAction = useTrashPostAction(); const permanentlyDeletePostAction = usePermanentlyDeletePostAction(); const restorePostAction = useRestorePostAction(); const editPostAction = useEditPostAction(); @@ -272,12 +271,7 @@ export default function PagePages() { editPostAction, postRevisionsAction, ], - [ - trashPostAction, - permanentlyDeletePostAction, - restorePostAction, - editPostAction, - ] + [ permanentlyDeletePostAction, restorePostAction, editPostAction ] ); const onChangeView = useCallback( ( viewUpdater ) => { From 70f76490e3f3fe3dbba0b3c7f342db1b00cf21ee Mon Sep 17 00:00:00 2001 From: James Koster Date: Tue, 28 Nov 2023 15:56:39 +0000 Subject: [PATCH 31/43] 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 32/43] 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 33/43] 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 34/43] 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 35/43] 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 36/43] 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 37/43] 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 38/43] 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 39/43] 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 40/43] 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 41/43] 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 42/43] 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 43/43] 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(), };