diff --git a/.eslintrc.js b/.eslintrc.js index 81408499bd34f4..5a939aeb9173b7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -278,7 +278,6 @@ module.exports = { }, }, { - // Temporary rules until we're ready to officially deprecate the bottom margins. files: [ 'packages/*/src/**/*.[tj]s?(x)' ], excludedFiles: [ 'packages/components/src/**/@(test|stories)/**', @@ -289,9 +288,12 @@ module.exports = { 'error', ...restrictedSyntax, ...restrictedSyntaxComponents, + // Temporary rules until we're ready to officially deprecate the bottom margins. ...[ + 'BaseControl', 'CheckboxControl', 'ComboboxControl', + 'DimensionControl', 'FocalPointPicker', 'RangeControl', 'SearchControl', @@ -307,6 +309,34 @@ module.exports = { componentName + ' should have the `__nextHasNoMarginBottom` prop to opt-in to the new margin-free styles.', } ) ), + // Temporary rules until we're ready to officially default to the new size. + ...[ + 'BorderBoxControl', + 'BorderControl', + 'ComboboxControl', + 'CustomSelectControl', + 'DimensionControl', + 'FontSizePicker', + 'NumberControl', + 'RangeControl', + 'ToggleGroupControl', + ].map( ( componentName ) => ( { + // Falsy `__next40pxDefaultSize` without a non-default `size` prop. + selector: `JSXOpeningElement[name.name="${ componentName }"]:not(:has(JSXAttribute[name.name="__next40pxDefaultSize"][value.expression.value!=false])):not(:has(JSXAttribute[name.name="size"][value.value!="default"]))`, + message: + componentName + + ' should have the `__next40pxDefaultSize` prop to opt-in to the new default size.', + } ) ), + // Temporary rules until all existing components have the `__next40pxDefaultSize` prop. + ...[ 'SelectControl', 'TextControl' ].map( + ( componentName ) => ( { + // Not strict. Allows pre-existing __next40pxDefaultSize={ false } usage until they are all manually updated. + selector: `JSXOpeningElement[name.name="${ componentName }"]:not(:has(JSXAttribute[name.name="__next40pxDefaultSize"])):not(:has(JSXAttribute[name.name="size"]))`, + message: + componentName + + ' should have the `__next40pxDefaultSize` prop to opt-in to the new default size.', + } ) + ), ], }, }, diff --git a/.github/workflows/props-bot.yml b/.github/workflows/props-bot.yml index 0f21f47ef14f99..b2332aabb816c7 100644 --- a/.github/workflows/props-bot.yml +++ b/.github/workflows/props-bot.yml @@ -18,7 +18,7 @@ on: # You cannot filter this event for PR comments only. # However, the logic below does short-circuit the workflow for issues. issue_comment: - type: + types: - created # This event will run everytime a new PR review is initially submitted. pull_request_review: diff --git a/backport-changelog/6.6/7145.md b/backport-changelog/6.6/7145.md new file mode 100644 index 00000000000000..386f765cb22fa8 --- /dev/null +++ b/backport-changelog/6.6/7145.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7145 + +* https://github.com/WordPress/gutenberg/pull/64076 diff --git a/backport-changelog/6.7/7125.md b/backport-changelog/6.7/7125.md new file mode 100644 index 00000000000000..ce208decd2d145 --- /dev/null +++ b/backport-changelog/6.7/7125.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7125 + +* https://github.com/WordPress/gutenberg/pull/61577 diff --git a/backport-changelog/6.7/7137.md b/backport-changelog/6.7/7137.md index 834cb29a21e6d9..00771b8bc6c21d 100644 --- a/backport-changelog/6.7/7137.md +++ b/backport-changelog/6.7/7137.md @@ -1,3 +1,5 @@ https://github.com/WordPress/wordpress-develop/pull/7137 +* https://github.com/WordPress/gutenberg/pull/64128 * https://github.com/WordPress/gutenberg/pull/64192 +* https://github.com/WordPress/gutenberg/pull/64328 diff --git a/backport-changelog/6.7/7179.md b/backport-changelog/6.7/7179.md new file mode 100644 index 00000000000000..d777eace2cb05e --- /dev/null +++ b/backport-changelog/6.7/7179.md @@ -0,0 +1,5 @@ +https://github.com/WordPress/wordpress-develop/pull/7179 + +* https://github.com/WordPress/gutenberg/pull/64401 +* https://github.com/WordPress/gutenberg/pull/64459 +* https://github.com/WordPress/gutenberg/pull/64477 diff --git a/changelog.txt b/changelog.txt index e85895547e87d1..748df8da3484c7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,7 +1,6 @@ == Changelog == -= 19.0.0-rc.1 = - += 19.0.0 = ## Changelog @@ -368,6 +367,8 @@ The following contributors merged PRs in this release: @aaronrobertshaw @adamsilverstein @afercia @akasunil @Aljullu @amitraj2203 @andrewserong @carolinan @cbravobernal @Chrico @ciampo @creativecoder @DaniGuardiola @DAreRodz @djcowan @ellatrix @jameskoster @jasmussen @jeryj @jorgefilipecosta @jsnajdr @kebbet @kmanijak @Mamaduka @matiasbenedetto @meteorlxy @mikachan @mirka @mtias @ndiego @noisysocks @oandregal @ramonjd @richtabor @Rishit30G @ryanwelcher @SantosGuillamot @scruffian @shail-mehta @simison @stokesman @t-hamano @talldan @tomdevisser @tomjn @tyxla @up1512001 @wzieba @youknowriad + + = 18.9.0 = ## Changelog diff --git a/docs/explanations/architecture/modularity.md b/docs/explanations/architecture/modularity.md index f94f8ec7b9472e..ff619ccbfdf5b7 100644 --- a/docs/explanations/architecture/modularity.md +++ b/docs/explanations/architecture/modularity.md @@ -42,7 +42,7 @@ function MyApp() { ```php // myplugin.php -// Example of script registration dependending on the "components" and "element packages. +// Example of script registration depending on the "components" and "element packages. wp_register_script( 'myscript', 'pathtomyscript.js', array ('wp-components', "react" ) ); ``` diff --git a/docs/getting-started/fundamentals/block-wrapper.md b/docs/getting-started/fundamentals/block-wrapper.md index 39c80262d7bcbe..98c435f6ebe2f7 100644 --- a/docs/getting-started/fundamentals/block-wrapper.md +++ b/docs/getting-started/fundamentals/block-wrapper.md @@ -102,7 +102,7 @@ The [example block](https://github.com/WordPress/block-development-examples/tree ## Dynamic render markup -In dynamic blocks, where the font-end markup is rendered server-side, you can utilize the [`get_block_wrapper_attributes()`](https://developer.wordpress.org/reference/functions/get_block_wrapper_attributes/) function to output the necessary classes and attributes just like you would use `useBlockProps.save()` in the `save` function. (See [example](https://github.com/WordPress/block-development-examples/blob/f68640f42d993f0866d1879f67c73910285ca114/plugins/block-dynamic-rendering-64756b/src/render.php#L11)) +In dynamic blocks, where the front-end markup is rendered server-side, you can utilize the [`get_block_wrapper_attributes()`](https://developer.wordpress.org/reference/functions/get_block_wrapper_attributes/) function to output the necessary classes and attributes just like you would use `useBlockProps.save()` in the `save` function. (See [example](https://github.com/WordPress/block-development-examples/blob/f68640f42d993f0866d1879f67c73910285ca114/plugins/block-dynamic-rendering-64756b/src/render.php#L11)) ```php

> diff --git a/docs/getting-started/fundamentals/static-dynamic-rendering.md b/docs/getting-started/fundamentals/static-dynamic-rendering.md index 8d199f66cccd2a..dfb6a7123b44b3 100644 --- a/docs/getting-started/fundamentals/static-dynamic-rendering.md +++ b/docs/getting-started/fundamentals/static-dynamic-rendering.md @@ -61,7 +61,7 @@ Dynamic blocks, which we'll explore in the following section, can specify an ini For a practical demonstration of how this works, refer to the [Building your first block](/docs/getting-started/tutorial.md) tutorial. Specifically, the [Adding static rendering](/docs/getting-started/tutorial.md#adding-static-rendering) section illustrates how a block can have both a saved HTML structure and dynamic rendering capabilities.

-WordPress provides mechanisms like the render_block are the $render_callback function to alter the saved HTML of a block before it appears on the front end. These tools offer developers the capability to customize block output dynamically, catering to complex and interactive user experiences. +WordPress provides mechanisms like the render_block and the render_callback function to alter the saved HTML of a block before it appears on the front end. These tools offer developers the capability to customize block output dynamically, catering to complex and interactive user experiences.
Additional examples of WordPress blocks that use static rendering, meaning their output is fixed at the time of saving and doesn't rely on server-side processing, include: diff --git a/docs/manifest.json b/docs/manifest.json index 1704e6d711510f..b483449872cc76 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -767,6 +767,12 @@ "markdown_source": "../packages/components/src/combobox-control/README.md", "parent": "components" }, + { + "title": "Composite", + "slug": "composite", + "markdown_source": "../packages/components/src/composite/README.md", + "parent": "components" + }, { "title": "ConfirmDialog", "slug": "confirm-dialog", diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 5beb712c80a113..b9cae44550181c 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -783,7 +783,7 @@ Give quoted text visual emphasis. "In quoting others, we cite ourselves." — Ju - **Name:** core/quote - **Category:** text -- **Supports:** anchor, background (backgroundImage, backgroundSize), color (background, gradients, heading, link, text), dimensions (minHeight), interactivity (clientNavigation), layout (~~allowEditing~~), spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~ +- **Supports:** align (full, left, right, wide), anchor, background (backgroundImage, backgroundSize), color (background, gradients, heading, link, text), dimensions (minHeight), interactivity (clientNavigation), layout (~~allowEditing~~), spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** citation, textAlign, value ## Read More diff --git a/docs/reference-guides/filters/block-filters.md b/docs/reference-guides/filters/block-filters.md index e7a31c1e3bbc83..637cecadf1402b 100644 --- a/docs/reference-guides/filters/block-filters.md +++ b/docs/reference-guides/filters/block-filters.md @@ -139,12 +139,12 @@ The following PHP filters are available to change the output of a block on the f ### `render_block` -Filters the font-end content of any block. This filter has no impact on the behavior of blocks in the Editor. +Filters the front-end content of any block. This filter has no impact on the behavior of blocks in the Editor. The callback function for this filter receives three parameters: - `$block_content` (`string`): The block content. -- `block` (`array`): The full block, including name and attributes. +- `$block` (`array`): The full block, including name and attributes. - `$instance` (`WP_Block`): The block instance. In the following example, the class `example-class` is added to all Paragraph blocks on the front end. Here the [HTML API](https://make.wordpress.org/core/2023/03/07/introducing-the-html-api-in-wordpress-6-2/) is used to easily add the class instead of relying on regex. @@ -172,12 +172,12 @@ add_filter( 'render_block', 'example_add_custom_class_to_paragraph_block', 10, 2 ### `render_block_{namespace/block}` -Filters the font-end content of the defined block. This is just a simpler form of `render_block` when you only need to modify a specific block type. +Filters the front-end content of the defined block. This is just a simpler form of `render_block` when you only need to modify a specific block type. The callback function for this filter receives three parameters: - `$block_content` (`string`): The block content. -- `block` (`array`): The full block, including name and attributes. +- `$block` (`array`): The full block, including name and attributes. - `$instance` (`WP_Block`): The block instance. In the following example, the class `example-class` is added to all Paragraph blocks on the front end. Notice that compared to the `render_block` example above, you no longer need to check the block type before modifying the content. Again, the [HTML API](https://make.wordpress.org/core/2023/03/07/introducing-the-html-api-in-wordpress-6-2/) is used instead of regex. diff --git a/docs/reference-guides/interactivity-api/api-reference.md b/docs/reference-guides/interactivity-api/api-reference.md index a4b400b8c0276b..46bd20bece0bda 100644 --- a/docs/reference-guides/interactivity-api/api-reference.md +++ b/docs/reference-guides/interactivity-api/api-reference.md @@ -776,7 +776,7 @@ Actions are just regular JavaScript functions. Usually triggered by the `data-wp ```ts const { state, actions } = store("myPlugin", { actions: { - selectItem: (id?: number) => { + selectItem: ( id ) => { const context = getContext(); // `id` is optional here, so this action can be used in a directive. state.selected = id || context.id; @@ -1152,7 +1152,7 @@ store('mySliderPlugin', { ## Server functions -The Interactivity API comes with handy functions on the PHP part. Apart from [setting the store via server](#on-the-server-side), there is also a function to get and set Interactivity related config variables. +The Interactivity API comes with handy functions that allow you to initialize and reference configuration options on the server. This is necessary to feed the initial data that the Server Directive Processing will use to modify the HTML markup before it's send to the browser. It is also a great way to leverage many of WordPress's APIs, like nonces, AJAX, and translations. ### wp_interactivity_config @@ -1181,6 +1181,53 @@ This config can be retrieved on the client: const { showLikeButton } = getConfig(); ``` +### wp_interactivity_state + +`wp_interactivity_state` allows the initialization of the global state on the server, which will be used to process the directives on the server and then will be merged with any global state defined in the client. + +Initializing the global state on the server also allows you to use many critical WordPress APIs, including [AJAX](https://developer.wordpress.org/plugins/javascript/ajax/), or [nonces](https://developer.wordpress.org/plugins/javascript/enqueuing/#nonce). + +The `wp_interactivity_state` function receives two arguments, a string with the namespace that will be used as a reference and an associative array containing the values. + +Here is an example of passing the WP Admin AJAX endpoint with a nonce. + +```php +// render.php + +wp_interactivity_state( + 'myPlugin', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'myPlugin_nonce' ), + ), +); +``` + +```js +// view.js + +const { state } = store( 'myPlugin', { + actions: { + *doSomething() { + try { + const formData = new FormData(); + formData.append( 'action', 'do_something' ); + formData.append( '_ajax_nonce', state.nonce ); + + const data = yield fetch( state.ajaxUrl, { + method: 'POST', + body: formData, + } ).then( ( response ) => response.json() ); + console.log( 'Server data!', data ); + } catch ( e ) { + // Something went wrong! + } + }, + }, + } +); +``` + ### wp_interactivity_process_directives `wp_interactivity_process_directives` returns the updated HTML after the directives have been processed. diff --git a/docs/reference-guides/slotfills/README.md b/docs/reference-guides/slotfills/README.md index 8b56ed4ce98b41..5ae68cdb5cb071 100644 --- a/docs/reference-guides/slotfills/README.md +++ b/docs/reference-guides/slotfills/README.md @@ -70,11 +70,10 @@ export default function PostSummary( { onActionPerformed } ) { const { isRemovedPostStatusPanel } = useSelect( ( select ) => { // We use isEditorPanelRemoved to hide the panel if it was programmatically removed. We do // not use isEditorPanelEnabled since this panel should not be disabled through the UI. - const { isEditorPanelRemoved, getCurrentPostType } = + const { isEditorPanelRemoved } = select( editorStore ); return { isRemovedPostStatusPanel: isEditorPanelRemoved( PANEL_NAME ), - postType: getCurrentPostType(), }; }, [] ); @@ -85,11 +84,7 @@ export default function PostSummary( { onActionPerformed } ) { <> - } + onActionPerformed={ onActionPerformed } /> diff --git a/lib/block-supports/background.php b/lib/block-supports/background.php index 811608127f47ed..a1d99133c1fc09 100644 --- a/lib/block-supports/background.php +++ b/lib/block-supports/background.php @@ -62,7 +62,7 @@ function gutenberg_render_background_support( $block_content, $block ) { $background_styles['backgroundSize'] = $background_styles['backgroundSize'] ?? 'cover'; // If the background size is set to `contain` and no position is set, set the position to `center`. if ( 'contain' === $background_styles['backgroundSize'] && ! $background_styles['backgroundPosition'] ) { - $background_styles['backgroundPosition'] = 'center'; + $background_styles['backgroundPosition'] = '50% 50%'; } } diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index ad8722091c2d48..756ef06c80aa87 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1744,7 +1744,7 @@ protected function get_layout_styles( $block_metadata, $types = array() ) { $spacing_rule['selector'] ); } else { - $format = static::ROOT_BLOCK_SELECTOR === $selector ? '.%2$s %3$s' : '%1$s-%2$s %3$s'; + $format = static::ROOT_BLOCK_SELECTOR === $selector ? ':root :where(.%2$s)%3$s' : ':root :where(%1$s-%2$s)%3$s'; $layout_selector = sprintf( $format, $selector, @@ -2329,7 +2329,7 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) { * ```php * array( * 'name' => 'property_name', - * 'value' => 'property_value, + * 'value' => 'property_value', * ) * ``` * @@ -2338,6 +2338,7 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) { * @since 6.1.0 Added `$theme_json`, `$selector`, and `$use_root_padding` parameters. * @since 6.5.0 Output a `min-height: unset` rule when `aspect-ratio` is set. * @since 6.6.0 Passing current theme JSON settings to wp_get_typography_font_size_value(). Using style engine to correctly fetch background CSS values. + * @since 6.7.0 Allow ref resolution of background properties. * * @param array $styles Styles to process. * @param array $settings Theme settings. @@ -2381,21 +2382,28 @@ protected static function compute_style_properties( $styles, $settings = array() $root_variable_duplicates[] = substr( $css_property, $root_style_length ); } - // Processes background styles. - if ( 'background' === $value_path[0] && isset( $styles['background'] ) ) { - /* - * For user-uploaded images at the block level, assign defaults. - * Matches defaults applied in the editor and in block supports: background.php. - */ - if ( static::ROOT_BLOCK_SELECTOR !== $selector && ! empty( $styles['background']['backgroundImage']['id'] ) ) { - $styles['background']['backgroundSize'] = $styles['background']['backgroundSize'] ?? 'cover'; - // If the background size is set to `contain` and no position is set, set the position to `center`. - if ( 'contain' === $styles['background']['backgroundSize'] && empty( $styles['background']['backgroundPosition'] ) ) { - $styles['background']['backgroundPosition'] = 'center'; - } + /* + * Processes background image styles. + * If the value is a URL, it will be converted to a CSS `url()` value. + * For an uploaded image (images with a database ID), apply size and position + * defaults equal to those applied in block supports in lib/background.php. + */ + if ( 'background-image' === $css_property && ! empty( $value ) ) { + $background_styles = gutenberg_style_engine_get_styles( + array( 'background' => array( 'backgroundImage' => $value ) ) + ); + + $value = $background_styles['declarations'][ $css_property ]; + } + if ( empty( $value ) && static::ROOT_BLOCK_SELECTOR !== $selector && ! empty( $styles['background']['backgroundImage']['id'] ) ) { + if ( 'background-size' === $css_property ) { + $value = 'cover'; + } + // If the background size is set to `contain` and no position is set, set the position to `center`. + if ( 'background-position' === $css_property ) { + $background_size = $styles['background']['backgroundSize'] ?? null; + $value = 'contain' === $background_size ? '50% 50%' : null; } - $background_styles = gutenberg_style_engine_get_styles( array( 'background' => $styles['background'] ) ); - $value = $background_styles['declarations'][ $css_property ] ?? $value; } // Skip if empty and not "0" or value represents array of longhand values. @@ -2463,6 +2471,7 @@ protected static function compute_style_properties( $styles, $settings = array() * @since 5.8.0 * @since 5.9.0 Added support for values of array type, which are returned as is. * @since 6.1.0 Added the `$theme_json` parameter. + * @since 6.7.0 Added support for background image refs * * @param array $styles Styles subtree. * @param array $path Which property to process. @@ -2479,15 +2488,17 @@ protected static function get_property_value( $styles, $path, $theme_json = null } /* - * This converts references to a path to the value at that path - * where the values is an array with a "ref" key, pointing to a path. + * Where the current value is an array with a 'ref' key pointing + * to a path, this converts that path into the value at that path. * For example: { "ref": "style.color.background" } => "#fff". */ if ( is_array( $value ) && isset( $value['ref'] ) ) { $value_path = explode( '.', $value['ref'] ); - $ref_value = _wp_array_get( $theme_json, $value_path ); + $ref_value = _wp_array_get( $theme_json, $value_path, null ); + // Background Image refs can refer to a string or an array containing a URL string. + $ref_value_url = $ref_value['url'] ?? null; // Only use the ref value if we find anything. - if ( ! empty( $ref_value ) && is_string( $ref_value ) ) { + if ( ! empty( $ref_value ) && ( is_string( $ref_value ) || is_string( $ref_value_url ) ) ) { $value = $ref_value; } @@ -3247,6 +3258,25 @@ public function merge( $incoming ) { } } } + + /* + * Style values are merged at the leaf level, however + * some values provide exceptions, namely style values that are + * objects and represent unique definitions for the style. + */ + $style_nodes = static::get_styles_block_nodes(); + foreach ( $style_nodes as $style_node ) { + $path = $style_node['path']; + /* + * Background image styles should be replaced, not merged, + * as they themselves are specific object definitions for the style. + */ + $background_image_path = array_merge( $path, static::PROPERTIES_METADATA['background-image'] ); + $content = _wp_array_get( $incoming_data, $background_image_path, null ); + if ( isset( $content ) ) { + _wp_array_set( $this->theme_json, $background_image_path, $content ); + } + } } /** diff --git a/lib/compat/wordpress-6.7/block-templates.php b/lib/compat/wordpress-6.7/block-templates.php new file mode 100644 index 00000000000000..e270ab226c1d9f --- /dev/null +++ b/lib/compat/wordpress-6.7/block-templates.php @@ -0,0 +1,41 @@ +register( $template_name, $args ); + } +} + +if ( ! function_exists( 'wp_unregister_block_template' ) ) { + /** + * Unregister a template. + * + * @param string $template_name Template name in the form of `plugin_uri//template_name`. + * @return true|WP_Error True on success, WP_Error on failure or if the template doesn't exist. + */ + function wp_unregister_block_template( $template_name ) { + return WP_Block_Templates_Registry::get_instance()->unregister( $template_name ); + } +} diff --git a/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php b/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php new file mode 100644 index 00000000000000..ed67dded75ecb1 --- /dev/null +++ b/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php @@ -0,0 +1,203 @@ +post_type ); + } else { + $template = get_block_template( $request['id'], $this->post_type ); + } + + if ( ! $template ) { + return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) ); + } + + return $this->prepare_item_for_response( $template, $request ); + } + + /** + * Prepare a single template output for response + * + * @param WP_Block_Template $item Template instance. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + // @core-merge: Fix wrong author in plugin templates. + public function prepare_item_for_response( $item, $request ) { + $template = $item; + + $fields = $this->get_fields_for_response( $request ); + + if ( 'plugin' !== $item->origin ) { + return parent::prepare_item_for_response( $item, $request ); + } + $cloned_item = clone $item; + // Set the origin as theme when calling the previous `prepare_item_for_response()` to prevent warnings when generating the author text. + $cloned_item->origin = 'theme'; + $response = parent::prepare_item_for_response( $cloned_item, $request ); + $data = $response->data; + + if ( rest_is_field_included( 'origin', $fields ) ) { + $data['origin'] = 'plugin'; + } + + if ( rest_is_field_included( 'plugin', $fields ) ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $cloned_item->slug ); + if ( $registered_template ) { + $data['plugin'] = $registered_template->plugin; + } + } + + if ( rest_is_field_included( 'author_text', $fields ) ) { + $data['author_text'] = $this->get_wp_templates_author_text_field( $template ); + } + + if ( rest_is_field_included( 'original_source', $fields ) ) { + $data['original_source'] = $this->get_wp_templates_original_source_field( $template ); + } + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $template->id ); + $response->add_links( $links ); + if ( ! empty( $links['self']['href'] ) ) { + $actions = $this->get_available_actions(); + $self = $links['self']['href']; + foreach ( $actions as $rel ) { + $response->add_link( $rel, $self ); + } + } + } + + return $response; + } + + /** + * Returns the source from where the template originally comes from. + * + * @param WP_Block_Template $template_object Template instance. + * @return string Original source of the template one of theme, plugin, site, or user. + */ + // @core-merge: Changed the comments format (from inline to multi-line) in the entire function. + private static function get_wp_templates_original_source_field( $template_object ) { + if ( 'wp_template' === $template_object->type || 'wp_template_part' === $template_object->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_object->has_theme_file && + ( 'theme' === $template_object->origin || ( + empty( $template_object->origin ) && in_array( + $template_object->source, + array( + 'theme', + 'custom', + ), + true + ) ) + ) + ) { + return 'theme'; + } + + // Added by plugin. + // @core-merge: Removed `$template_object->has_theme_file` check from this if clause. + if ( 'plugin' === $template_object->origin ) { + return 'plugin'; + } + + /* + * 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 ( empty( $template_object->has_theme_file ) && 'custom' === $template_object->source && empty( $template_object->author ) ) { + return 'site'; + } + } + + // Added by user. + return 'user'; + } + + /** + * Returns a human readable text for the author of the template. + * + * @param WP_Block_Template $template_object Template instance. + * @return string Human readable text for the author. + */ + private static function get_wp_templates_author_text_field( $template_object ) { + $original_source = self::get_wp_templates_original_source_field( $template_object ); + switch ( $original_source ) { + case 'theme': + $theme_name = wp_get_theme( $template_object->theme )->get( 'Name' ); + return empty( $theme_name ) ? $template_object->theme : $theme_name; + case 'plugin': + // @core-merge: Prioritize plugin name instead of theme name for plugin-registered templates. + if ( ! function_exists( 'get_plugins' ) || ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( isset( $template_object->plugin ) ) { + $plugins = wp_get_active_and_valid_plugins(); + + foreach ( $plugins as $plugin_file ) { + $plugin_basename = plugin_basename( $plugin_file ); + // Split basename by '/' to get the plugin slug. + list( $plugin_slug, ) = explode( '/', $plugin_basename ); + + if ( $plugin_slug === $template_object->plugin ) { + $plugin_data = get_plugin_data( $plugin_file ); + + if ( ! empty( $plugin_data['Name'] ) ) { + return $plugin_data['Name']; + } + + break; + } + } + } + + /* + * Fall back to the theme name if the plugin is not defined. That's needed to keep backwards + * compatibility with templates that were registered before the plugin attribute was added. + */ + $plugins = get_plugins(); + $plugin_basename = plugin_basename( sanitize_text_field( $template_object->theme . '.php' ) ); + if ( isset( $plugins[ $plugin_basename ] ) && isset( $plugins[ $plugin_basename ]['Name'] ) ) { + return $plugins[ $plugin_basename ]['Name']; + } + return isset( $template_object->plugin ) ? + $template_object->plugin : + $template_object->theme; + // @core-merge: End of changes to merge in core. + case 'site': + return get_bloginfo( 'name' ); + case 'user': + $author = get_user_by( 'id', $template_object->author ); + if ( ! $author ) { + return __( 'Unknown author' ); + } + return $author->get( 'display_name' ); + } + } +} diff --git a/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php b/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php new file mode 100644 index 00000000000000..db53f735e13b3d --- /dev/null +++ b/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php @@ -0,0 +1,256 @@ + $instance` pairs. + * + * @since 6.7.0 + * @var WP_Block_Template[] $registered_block_templates Registered templates. + */ + private $registered_templates = array(); + + /** + * Container for the main instance of the class. + * + * @since 6.7.0 + * @var WP_Block_Templates_Registry|null + */ + private static $instance = null; + + /** + * Registers a template. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @param array $args Optional. Array of template arguments. + * @return WP_Block_Template|WP_Error The registered template on success, or false on failure. + */ + public function register( $template_name, $args = array() ) { + + $template = null; + + $error_message = ''; + $error_code = ''; + if ( ! is_string( $template_name ) ) { + $error_message = __( 'Template names must be strings.', 'gutenberg' ); + $error_code = 'template_name_no_string'; + } elseif ( preg_match( '/[A-Z]+/', $template_name ) ) { + $error_message = __( 'Template names must not contain uppercase characters.', 'gutenberg' ); + $error_code = 'template_name_no_uppercase'; + } elseif ( ! preg_match( '/^[a-z0-9-]+\/\/[a-z0-9-]+$/', $template_name ) ) { + $error_message = __( 'Template names must contain a namespace prefix. Example: my-plugin//my-custom-template', 'gutenberg' ); + $error_code = 'template_no_prefix'; + } elseif ( $this->is_registered( $template_name ) ) { + /* translators: %s: Template name. */ + $error_message = sprintf( __( 'Template "%s" is already registered.', 'gutenberg' ), $template_name ); + $error_code = 'template_already_registered'; + } + + if ( $error_message ) { + _doing_it_wrong( + __METHOD__, + $error_message, + '6.7.0' + ); + return new WP_Error( $error_code, $error_message ); + } + + if ( ! $template ) { + $theme_name = get_stylesheet(); + list( $plugin, $slug ) = explode( '//', $template_name ); + $default_template_types = get_default_block_template_types(); + + $template = new WP_Block_Template(); + $template->id = $theme_name . '//' . $slug; + $template->theme = $theme_name; + $template->plugin = $plugin; + $template->author = null; + $template->content = isset( $args['content'] ) ? $args['content'] : ''; + $template->source = 'plugin'; + $template->slug = $slug; + $template->type = 'wp_template'; + $template->title = isset( $args['title'] ) ? $args['title'] : $template_name; + $template->description = isset( $args['description'] ) ? $args['description'] : ''; + $template->status = 'publish'; + $template->origin = 'plugin'; + $template->is_custom = ! isset( $default_template_types[ $template_name ] ); + $template->post_types = isset( $args['post_types'] ) ? $args['post_types'] : array(); + } + + $this->registered_templates[ $template_name ] = $template; + + return $template; + } + + /** + * Retrieves all registered templates. + * + * @since 6.7.0 + * + * @return WP_Block_Template[]|false Associative array of `$template_name => $template` pairs. + */ + public function get_all_registered() { + return $this->registered_templates; + } + + /** + * Retrieves a registered template by its name. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @return WP_Block_Template|null|false The registered template, or null if it is not registered. + */ + public function get_registered( $template_name ) { + if ( ! $this->is_registered( $template_name ) ) { + return null; + } + + return $this->registered_templates[ $template_name ]; + } + + /** + * Retrieves a registered template by its slug. + * + * @since 6.7.0 + * + * @param string $template_slug Slug of the template. + * @return WP_Block_Template|null The registered template, or null if it is not registered. + */ + public function get_by_slug( $template_slug ) { + $all_templates = $this->get_all_registered(); + + if ( ! $all_templates ) { + return null; + } + + foreach ( $all_templates as $template ) { + if ( $template->slug === $template_slug ) { + return $template; + } + } + + return null; + } + + /** + * Retrieves registered templates matching a query. + * + * @since 6.7.0 + * + * @param array $query { + * Arguments to retrieve templates. Optional, empty by default. + * + * @type string[] $slug__in List of slugs to include. + * @type string[] $slug__not_in List of slugs to skip. + * @type string $post_type Post type to get the templates for. + * } + */ + public function get_by_query( $query = array() ) { + $all_templates = $this->get_all_registered(); + + if ( ! $all_templates ) { + return array(); + } + + $query = wp_parse_args( + $query, + array( + 'slug__in' => array(), + 'slug__not_in' => array(), + 'post_type' => '', + ) + ); + $slugs_to_include = $query['slug__in']; + $slugs_to_skip = $query['slug__not_in']; + $post_type = $query['post_type']; + + $matching_templates = array(); + foreach ( $all_templates as $template_name => $template ) { + if ( $slugs_to_include && ! in_array( $template->slug, $slugs_to_include, true ) ) { + continue; + } + + if ( $slugs_to_skip && in_array( $template->slug, $slugs_to_skip, true ) ) { + continue; + } + + if ( $post_type && ! in_array( $post_type, $template->post_types, true ) ) { + continue; + } + + $matching_templates[ $template_name ] = $template; + } + + return $matching_templates; + } + + /** + * Checks if a template is registered. + * + * @since 6.7.0 + * + * @param string $template_name Template name. + * @return bool True if the template is registered, false otherwise. + */ + public function is_registered( $template_name ) { + return isset( $this->registered_templates[ $template_name ] ); + } + + /** + * Unregisters a template. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @return WP_Block_Template|false The unregistered template on success, or false on failure. + */ + public function unregister( $template_name ) { + if ( ! $this->is_registered( $template_name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Template name. */ + sprintf( __( 'Template "%s" is not registered.', 'gutenberg' ), $template_name ), + '6.7.0' + ); + /* translators: %s: Template name. */ + return new WP_Error( 'template_not_registered', __( 'Template "%s" is not registered.', 'gutenberg' ) ); + } + + $unregistered_template = $this->registered_templates[ $template_name ]; + unset( $this->registered_templates[ $template_name ] ); + + return $unregistered_template; + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.7.0 + * + * @return WP_Block_Templates_Registry The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + } +} diff --git a/lib/compat/wordpress-6.7/compat.php b/lib/compat/wordpress-6.7/compat.php new file mode 100644 index 00000000000000..edc8e3fa5fb03f --- /dev/null +++ b/lib/compat/wordpress-6.7/compat.php @@ -0,0 +1,114 @@ + $value ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $query_result[ $key ]->slug ); + if ( $registered_template ) { + $query_result[ $key ]->plugin = $registered_template->plugin; + $query_result[ $key ]->origin = + 'theme' !== $query_result[ $key ]->origin && 'theme' !== $query_result[ $key ]->source ? + 'plugin' : + $query_result[ $key ]->origin; + } + } + + if ( ! isset( $query['wp_id'] ) ) { + $template_files = _gutenberg_get_block_templates_files( $template_type, $query ); + + /* + * Add templates registered in the template registry. Filtering out the ones which have a theme file. + */ + $registered_templates = WP_Block_Templates_Registry::get_instance()->get_by_query( $query ); + $matching_registered_templates = array_filter( + $registered_templates, + function ( $registered_template ) use ( $template_files ) { + foreach ( $template_files as $template_file ) { + if ( $template_file['slug'] === $registered_template->slug ) { + return false; + } + } + return true; + } + ); + $query_result = array_merge( $query_result, $matching_registered_templates ); + } + + return $query_result; +} +add_filter( 'get_block_templates', '_gutenberg_add_block_templates_from_registry', 10, 3 ); + +/** + * Hooks into `get_block_template` to add the `plugin` property when necessary. + * + * @param [WP_Block_Template|null] $block_template The found block template, or null if there isn’t one. + * @return [WP_Block_Template|null] The block template that was already found with the plugin property defined if it was registered by a plugin. + */ +function _gutenberg_add_block_template_plugin_attribute( $block_template ) { + if ( $block_template ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $block_template->slug ); + if ( $registered_template ) { + $block_template->plugin = $registered_template->plugin; + $block_template->origin = + 'theme' !== $block_template->origin && 'theme' !== $block_template->source ? + 'plugin' : + $block_template->origin; + } + } + + return $block_template; +} +add_filter( 'get_block_template', '_gutenberg_add_block_template_plugin_attribute', 10, 1 ); + +/** + * Hooks into `get_block_file_template` so templates from the registry are also returned. + * + * @param WP_Block_Template|null $block_template The found block template, or null if there is none. + * @param string $id Template unique identifier (example: 'theme_slug//template_slug'). + * @return WP_Block_Template|null The block template that was already found or from the registry. In case the template was already found, add the necessary details from the registry. + */ +function _gutenberg_add_block_file_templates_from_registry( $block_template, $id ) { + if ( $block_template ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $block_template->slug ); + if ( $registered_template ) { + $block_template->plugin = $registered_template->plugin; + $block_template->origin = + 'theme' !== $block_template->origin && 'theme' !== $block_template->source ? + 'plugin' : + $block_template->origin; + } + return $block_template; + } + + $parts = explode( '//', $id, 2 ); + + if ( count( $parts ) < 2 ) { + return $block_template; + } + + list( , $slug ) = $parts; + return WP_Block_Templates_Registry::get_instance()->get_by_slug( $slug ); +} +add_filter( 'get_block_file_template', '_gutenberg_add_block_file_templates_from_registry', 10, 2 ); diff --git a/lib/compat/wordpress-6.7/rest-api.php b/lib/compat/wordpress-6.7/rest-api.php new file mode 100644 index 00000000000000..081c22c8102914 --- /dev/null +++ b/lib/compat/wordpress-6.7/rest-api.php @@ -0,0 +1,100 @@ +name ) { + // Fixes post type name. It should be `type/wp_template_part`. + $parts_key = array_search( '/wp/v2/types/wp_template-part?context=edit', $paths, true ); + if ( false !== $parts_key ) { + $paths[ $parts_key ] = '/wp/v2/types/wp_template_part?context=edit'; + } + } + + if ( 'core/edit-post' === $context->name ) { + $reusable_blocks_key = array_search( + add_query_arg( + array( + 'context' => 'edit', + 'per_page' => -1, + ), + rest_get_route_for_post_type_items( 'wp_block' ) + ), + $paths, + true + ); + + if ( false !== $reusable_blocks_key ) { + unset( $paths[ $reusable_blocks_key ] ); + } + } + + return $paths; +} +add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_6_7', 10, 2 ); + +if ( ! function_exists( 'wp_api_template_registry' ) ) { + /** + * Hook in to the template and template part post types and modify the rest + * endpoint to include modifications to read templates from the + * BlockTemplatesRegistry. + * + * @param array $args Current registered post type args. + * @param string $post_type Name of post type. + * + * @return array + */ + function wp_api_template_registry( $args, $post_type ) { + if ( 'wp_template' === $post_type || 'wp_template_part' === $post_type ) { + $args['rest_controller_class'] = 'Gutenberg_REST_Templates_Controller_6_7'; + } + return $args; + } +} +add_filter( 'register_post_type_args', 'wp_api_template_registry', 10, 2 ); + +/** + * Adds `plugin` fields to WP_REST_Templates_Controller class. + */ +function gutenberg_register_wp_rest_templates_controller_plugin_field() { + + register_rest_field( + 'wp_template', + 'plugin', + array( + 'get_callback' => function ( $template_object ) { + if ( $template_object ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $template_object['slug'] ); + if ( $registered_template ) { + return $registered_template->plugin; + } + } + + return; + }, + 'update_callback' => null, + 'schema' => array( + 'type' => 'string', + 'description' => __( 'Plugin that registered the template.', 'gutenberg' ), + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_wp_rest_templates_controller_plugin_field' ); diff --git a/lib/compat/wordpress-6.7/script-modules.php b/lib/compat/wordpress-6.7/script-modules.php new file mode 100644 index 00000000000000..0a440ec81688d2 --- /dev/null +++ b/lib/compat/wordpress-6.7/script-modules.php @@ -0,0 +1,104 @@ +setAccessible( true ); + $get_import_map = new ReflectionMethod( 'WP_Script_Modules', 'get_import_map' ); + $get_import_map->setAccessible( true ); + + $modules = array(); + foreach ( array_keys( $get_marked_for_enqueue->invoke( wp_script_modules() ) ) as $id ) { + $modules[ $id ] = true; + } + foreach ( array_keys( $get_import_map->invoke( wp_script_modules() )['imports'] ) as $id ) { + $modules[ $id ] = true; + } + + foreach ( array_keys( $modules ) as $module_id ) { + /** + * Filters data associated with a given Script Module. + * + * Script Modules may require data that is required for initialization or is essential to + * have immediately available on page load. These are suitable use cases for this data. + * + * This is best suited to a minimal set of data and is not intended to replace the REST API. + * + * If the filter returns no data (an empty array), nothing will be embedded in the page. + * + * The data for a given Script Module, if provided, will be JSON serialized in a script tag + * with an ID like `wp-script-module-data-{$module_id}`. + * + * The dynamic portion of the hook name, `$module_id`, refers to the Script Module ID that + * the data is associated with. + * + * @param array $data The data that should be associated with the array. + */ + $data = apply_filters( "script_module_data_{$module_id}", array() ); + + if ( is_array( $data ) && ! empty( $data ) ) { + /* + * This data will be printed as JSON inside a script tag like this: + * + * + * A script tag must be closed by a sequence beginning with `` will be printed as `\u003C/script\u00E3`. + * + * - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E. + * - JSON_UNESCAPED_SLASHES: Don't escape /. + * + * If the page will use UTF-8 encoding, it's safe to print unescaped unicode: + * + * - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`). + * - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when + * JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was + * before PHP 7.1 without this constant. Available as of PHP 7.1.0. + * + * The JSON specification requires encoding in UTF-8, so if the generated HTML page + * is not encoded in UTF-8 then it's not safe to include those literals. They must + * be escaped to avoid encoding issues. + * + * @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements. + * @see https://www.php.net/manual/en/json.constants.php for details on these constants. + * @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing. + */ + $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS; + if ( 'UTF-8' !== get_option( 'blog_charset' ) ) { + $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES; + } + + wp_print_inline_script_tag( + wp_json_encode( $data, $json_encode_flags ), + array( + 'type' => 'application/json', + 'id' => "wp-script-module-data-{$module_id}", + ) + ); + } + } +} + +add_action( + 'after_setup_theme', + function () { + if ( ! has_action( 'wp_footer', array( wp_script_modules(), 'print_script_module_data' ) ) ) { + add_action( 'wp_footer', 'gutenberg_print_script_module_data' ); + } + + if ( ! has_action( 'admin_print_footer_scripts', array( wp_script_modules(), 'print_script_module_data' ) ) ) { + add_action( 'admin_print_footer_scripts', 'gutenberg_print_script_module_data' ); + } + }, + 20 +); diff --git a/lib/experimental/script-modules.php b/lib/experimental/script-modules.php index 0093c2e974568f..5a14e1418ed6de 100644 --- a/lib/experimental/script-modules.php +++ b/lib/experimental/script-modules.php @@ -200,96 +200,3 @@ function gutenberg_dequeue_module( $module_identifier ) { _deprecated_function( __FUNCTION__, 'Gutenberg 17.6.0', 'wp_dequeue_script_module' ); wp_script_modules()->dequeue( $module_identifier ); } - - -/** - * Print data associated with Script Modules in Script tags. - * - * This embeds data in the page HTML so that it is available on page load. - * - * Data can be associated with a given Script Module by using the - * `script_module_data_{$module_id}` filter. - * - * The data for a given Script Module will be JSON serialized in a script tag with an ID - * like `wp-script-module-data-{$module_id}`. - */ -function gutenberg_print_script_module_data(): void { - $get_marked_for_enqueue = new ReflectionMethod( 'WP_Script_Modules', 'get_marked_for_enqueue' ); - $get_marked_for_enqueue->setAccessible( true ); - $get_import_map = new ReflectionMethod( 'WP_Script_Modules', 'get_import_map' ); - $get_import_map->setAccessible( true ); - - $modules = array(); - foreach ( array_keys( $get_marked_for_enqueue->invoke( wp_script_modules() ) ) as $id ) { - $modules[ $id ] = true; - } - foreach ( array_keys( $get_import_map->invoke( wp_script_modules() )['imports'] ) as $id ) { - $modules[ $id ] = true; - } - - foreach ( array_keys( $modules ) as $module_id ) { - /** - * Filters data associated with a given Script Module. - * - * Script Modules may require data that is required for initialization or is essential to - * have immediately available on page load. These are suitable use cases for this data. - * - * This is best suited to a minimal set of data and is not intended to replace the REST API. - * - * If the filter returns no data (an empty array), nothing will be embedded in the page. - * - * The data for a given Script Module, if provided, will be JSON serialized in a script tag - * with an ID like `wp-script-module-data-{$module_id}`. - * - * The dynamic portion of the hook name, `$module_id`, refers to the Script Module ID that - * the data is associated with. - * - * @param array $data The data that should be associated with the array. - */ - $data = apply_filters( "script_module_data_{$module_id}", array() ); - - if ( is_array( $data ) && ! empty( $data ) ) { - /* - * This data will be printed as JSON inside a script tag like this: - * - * - * A script tag must be closed by a sequence beginning with `` will be printed as `\u003C/script\u00E3`. - * - * - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E. - * - JSON_UNESCAPED_SLASHES: Don't escape /. - * - * If the page will use UTF-8 encoding, it's safe to print unescaped unicode: - * - * - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`). - * - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when - * JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was - * before PHP 7.1 without this constant. Available as of PHP 7.1.0. - * - * The JSON specification requires encoding in UTF-8, so if the generated HTML page - * is not encoded in UTF-8 then it's not safe to include those literals. They must - * be escaped to avoid encoding issues. - * - * @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements. - * @see https://www.php.net/manual/en/json.constants.php for details on these constants. - * @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing. - */ - $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS; - if ( 'UTF-8' !== get_option( 'blog_charset' ) ) { - $json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES; - } - - wp_print_inline_script_tag( - wp_json_encode( $data, $json_encode_flags ), - array( - 'type' => 'application/json', - 'id' => "wp-script-module-data-{$module_id}", - ) - ); - } - } -} - -add_action( 'wp_footer', 'gutenberg_print_script_module_data' ); -add_action( 'admin_print_footer_scripts', 'gutenberg_print_script_module_data' ); diff --git a/lib/load.php b/lib/load.php index 5a299f3b696968..b501f0abd1c978 100644 --- a/lib/load.php +++ b/lib/load.php @@ -40,6 +40,10 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.6/class-gutenberg-rest-templates-controller-6-6.php'; require __DIR__ . '/compat/wordpress-6.6/rest-api.php'; + // WordPress 6.7 compat. + require __DIR__ . '/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php'; + require __DIR__ . '/compat/wordpress-6.7/rest-api.php'; + // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; require_once __DIR__ . '/class-wp-rest-edit-site-export-controller-gutenberg.php'; @@ -98,8 +102,12 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.6/post.php'; // WordPress 6.7 compat. +require __DIR__ . '/compat/wordpress-6.7/block-templates.php'; require __DIR__ . '/compat/wordpress-6.7/blocks.php'; require __DIR__ . '/compat/wordpress-6.7/block-bindings.php'; +require __DIR__ . '/compat/wordpress-6.7/script-modules.php'; +require __DIR__ . '/compat/wordpress-6.7/class-wp-block-templates-registry.php'; +require __DIR__ . '/compat/wordpress-6.7/compat.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/package-lock.json b/package-lock.json index b0eccc961afb78..ddfec3a5dddc63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "19.0.0-rc.1", + "version": "19.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "19.0.0-rc.1", + "version": "19.0.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -99,7 +99,7 @@ "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", - "@playwright/test": "1.45.1", + "@playwright/test": "1.46.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@react-native/babel-preset": "0.73.10", "@react-native/metro-babel-transformer": "0.73.10", @@ -6950,12 +6950,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.1.tgz", - "integrity": "sha512-Wo1bWTzQvGA7LyKGIZc8nFSTFf2TkthGIFBR+QVNilvwouGzFd4PYukZe3rvf5PSqjHi1+1NyKSDZKcQWETzaA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", "dev": true, "dependencies": { - "playwright": "1.45.1" + "playwright": "1.46.0" }, "bin": { "playwright": "cli.js" @@ -41083,12 +41083,12 @@ "dev": true }, "node_modules/playwright": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.1.tgz", - "integrity": "sha512-Hjrgae4kpSQBr98nhCj3IScxVeVUixqj+5oyif8TdIn2opTCPEzqAqNMeK42i3cWDCVu9MI+ZsGWw+gVR4ISBg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", "dev": true, "dependencies": { - "playwright-core": "1.45.1" + "playwright-core": "1.46.0" }, "bin": { "playwright": "cli.js" @@ -41101,9 +41101,9 @@ } }, "node_modules/playwright-core": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.1.tgz", - "integrity": "sha512-LF4CUUtrUu2TCpDw4mcrAIuYrEjVDfT1cHbJMfwnE2+1b8PZcFzPNgvZCvq2JfQ4aTjRCCHw5EJ2tmr2NSzdPg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -41754,14 +41754,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, - "node_modules/postcss-prefixwrap": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/postcss-prefixwrap/-/postcss-prefixwrap-1.41.0.tgz", - "integrity": "sha512-gmwwAEE+ci3/ZKjUZppTETINlh1QwihY8gCstInuS7ohk353KYItU4d64hvnUvO2GUy29hBGPHz4Ce+qJRi90A==", - "peerDependencies": { - "postcss": "*" - } - }, "node_modules/postcss-reduce-initial": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.0.0.tgz", @@ -52237,7 +52229,7 @@ "fast-deep-equal": "^3.1.3", "memize": "^2.1.0", "postcss": "^8.4.21", - "postcss-prefixwrap": "^1.41.0", + "postcss-prefixwrap": "^1.51.0", "postcss-urlrebase": "^1.4.0", "react-autosize-textarea": "^7.1.0", "react-easy-crop": "^5.0.6", @@ -52252,6 +52244,15 @@ "react-dom": "^18.0.0" } }, + "packages/block-editor/node_modules/postcss-prefixwrap": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/postcss-prefixwrap/-/postcss-prefixwrap-1.51.0.tgz", + "integrity": "sha512-PuP4md5zFSY921dUcLShwSLv2YyyDec0dK9/puXl/lu7ZNvJ1U59+ZEFRMS67xwfNg5nIIlPXnAycPJlhA/Isw==", + "license": "MIT", + "peerDependencies": { + "postcss": "*" + } + }, "packages/block-editor/node_modules/postcss-urlrebase": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/postcss-urlrebase/-/postcss-urlrebase-1.4.0.tgz", @@ -54585,7 +54586,7 @@ "npm": ">=8.19.2" }, "peerDependencies": { - "@playwright/test": "^1.45.1", + "@playwright/test": "^1.46.0", "react": "^18.0.0", "react-dom": "^18.0.0" } @@ -60001,12 +60002,12 @@ } }, "@playwright/test": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.1.tgz", - "integrity": "sha512-Wo1bWTzQvGA7LyKGIZc8nFSTFf2TkthGIFBR+QVNilvwouGzFd4PYukZe3rvf5PSqjHi1+1NyKSDZKcQWETzaA==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", "dev": true, "requires": { - "playwright": "1.45.1" + "playwright": "1.46.0" } }, "@pmmmwh/react-refresh-webpack-plugin": { @@ -67255,13 +67256,18 @@ "fast-deep-equal": "^3.1.3", "memize": "^2.1.0", "postcss": "^8.4.21", - "postcss-prefixwrap": "^1.41.0", + "postcss-prefixwrap": "^1.51.0", "postcss-urlrebase": "^1.4.0", "react-autosize-textarea": "^7.1.0", "react-easy-crop": "^5.0.6", "remove-accents": "^0.5.0" }, "dependencies": { + "postcss-prefixwrap": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/postcss-prefixwrap/-/postcss-prefixwrap-1.51.0.tgz", + "integrity": "sha512-PuP4md5zFSY921dUcLShwSLv2YyyDec0dK9/puXl/lu7ZNvJ1U59+ZEFRMS67xwfNg5nIIlPXnAycPJlhA/Isw==" + }, "postcss-urlrebase": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/postcss-urlrebase/-/postcss-urlrebase-1.4.0.tgz", @@ -87337,19 +87343,19 @@ "dev": true }, "playwright": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.1.tgz", - "integrity": "sha512-Hjrgae4kpSQBr98nhCj3IScxVeVUixqj+5oyif8TdIn2opTCPEzqAqNMeK42i3cWDCVu9MI+ZsGWw+gVR4ISBg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.45.1" + "playwright-core": "1.46.0" } }, "playwright-core": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.1.tgz", - "integrity": "sha512-LF4CUUtrUu2TCpDw4mcrAIuYrEjVDfT1cHbJMfwnE2+1b8PZcFzPNgvZCvq2JfQ4aTjRCCHw5EJ2tmr2NSzdPg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", "dev": true }, "please-upgrade-node": { @@ -87820,11 +87826,6 @@ } } }, - "postcss-prefixwrap": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/postcss-prefixwrap/-/postcss-prefixwrap-1.41.0.tgz", - "integrity": "sha512-gmwwAEE+ci3/ZKjUZppTETINlh1QwihY8gCstInuS7ohk353KYItU4d64hvnUvO2GUy29hBGPHz4Ce+qJRi90A==" - }, "postcss-reduce-initial": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-6.0.0.tgz", diff --git a/package.json b/package.json index fac57093a852c9..ee78f197a43e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "19.0.0-rc.1", + "version": "19.0.0", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -111,7 +111,7 @@ "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", - "@playwright/test": "1.45.1", + "@playwright/test": "1.46.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@react-native/babel-preset": "0.73.10", "@react-native/metro-babel-transformer": "0.73.10", diff --git a/packages/base-styles/_colors.scss b/packages/base-styles/_colors.scss index 2ce58b64e43b8c..e65551e48c783c 100644 --- a/packages/base-styles/_colors.scss +++ b/packages/base-styles/_colors.scss @@ -17,7 +17,6 @@ $gray-100: #f0f0f0; // Used for light gray backgrounds. $white: #fff; // Opacities & additional colors. -$dark-theme-focus: $white; // Focus color when the theme is dark. $dark-gray-placeholder: rgba($gray-900, 0.62); $medium-gray-placeholder: rgba($gray-900, 0.55); $light-gray-placeholder: rgba($white, 0.65); @@ -26,3 +25,6 @@ $light-gray-placeholder: rgba($white, 0.65); $alert-yellow: #f0b849; $alert-red: #cc1818; $alert-green: #4ab866; + +// Deprecated, please avoid using these. +$dark-theme-focus: $white; // Focus color when the theme is dark. diff --git a/packages/base-styles/_variables.scss b/packages/base-styles/_variables.scss index 97eb513cf38aeb..0f0c9e6d019ba3 100644 --- a/packages/base-styles/_variables.scss +++ b/packages/base-styles/_variables.scss @@ -47,9 +47,25 @@ $radius-x-small: 1px; // Applied to elements like buttons nested within primit $radius-small: 2px; // Applied to most primitives. $radius-medium: 4px; // Applied to containers with smaller padding. $radius-large: 8px; // Applied to containers with larger padding. -$radius-full: 9999px; // For lozenges. +$radius-full: 9999px; // For pills. $radius-round: 50%; // For circles and ovals. +/** + * Elevation scale. + */ + +// For sections and containers that group related content and controls, which may overlap other content. Example: Preview Frame. +$elevation-x-small: 0 0.7px 1px rgba($black, 0.1), 0 1.2px 1.7px -0.2px rgba($black, 0.1), 0 2.3px 3.3px -0.5px rgba($black, 0.1); + +// For components that provide contextual feedback without being intrusive. Generally non-interruptive. Example: Tooltips, Snackbar. +$elevation-small: 0 0.7px 1px 0 rgba(0, 0, 0, 0.12), 0 2.2px 3.7px -0.2px rgba(0, 0, 0, 0.12), 0 5.3px 7.3px -0.5px rgba(0, 0, 0, 0.12); + +// For components that offer additional actions. Example: Menus, Command Palette +$elevation-medium: 0 0.7px 1px 0 rgba(0, 0, 0, 0.14), 0 4.2px 5.7px -0.2px rgba(0, 0, 0, 0.14), 0 7.3px 9.3px -0.5px rgba(0, 0, 0, 0.14); + +// For components that confirm decisions or handle necessary interruptions. Example: Modals. +$elevation-large: 0 0.7px 1px rgba($black, 0.15), 0 2.7px 3.8px -0.2px rgba($black, 0.15), 0 5.5px 7.8px -0.3px rgba($black, 0.15), 0.1px 11.5px 16.4px -0.5px rgba($black, 0.15); + /** * Dimensions. */ @@ -74,14 +90,6 @@ $modal-width-large: 840px; $spinner-size: 16px; $canvas-padding: $grid-unit-20; - -/** - * Shadows. - */ - -$shadow-popover: 0 0.7px 1px rgba($black, 0.1), 0 1.2px 1.7px -0.2px rgba($black, 0.1), 0 2.3px 3.3px -0.5px rgba($black, 0.1); -$shadow-modal: 0 0.7px 1px rgba($black, 0.15), 0 2.7px 3.8px -0.2px rgba($black, 0.15), 0 5.5px 7.8px -0.3px rgba($black, 0.15), 0.1px 11.5px 16.4px -0.5px rgba($black, 0.15); - /** * Editor widths. */ @@ -107,6 +115,8 @@ $radio-input-size-sm: 24px; // Width & height for small viewports. // Deprecated, please avoid using these. $block-padding: 14px; // Used to define space between block footprint and surrouding borders. $radius-block-ui: $radius-small; +$shadow-popover: $elevation-x-small; +$shadow-modal: $elevation-large; /** diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 776b217ba54f6e..c798015804b3e5 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -920,20 +920,15 @@ _Usage_ import { useBlockProps } from '@wordpress/block-editor'; export default function Edit() { - - const blockProps = useBlockProps( - className: 'my-custom-class', - style: { - color: '#222222', - backgroundColor: '#eeeeee' - } - ) - - return ( -
- -
- ) + const blockProps = useBlockProps( { + className: 'my-custom-class', + style: { + color: '#222222', + backgroundColor: '#eeeeee', + }, + } ); + + return
; } ``` diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 8ccaee6f0a955c..02765376e314b6 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -73,7 +73,7 @@ "fast-deep-equal": "^3.1.3", "memize": "^2.1.0", "postcss": "^8.4.21", - "postcss-prefixwrap": "^1.41.0", + "postcss-prefixwrap": "^1.51.0", "postcss-urlrebase": "^1.4.0", "react-autosize-textarea": "^7.1.0", "react-easy-crop": "^5.0.6", diff --git a/packages/block-editor/src/autocompleters/block.js b/packages/block-editor/src/autocompleters/block.js index bc06c9de5aaaff..859ae11036c82a 100644 --- a/packages/block-editor/src/autocompleters/block.js +++ b/packages/block-editor/src/autocompleters/block.js @@ -60,7 +60,8 @@ function createBlockCompleter() { }, [] ); const [ items, categories, collections ] = useBlockTypesState( rootClientId, - noop + noop, + true ); const filteredItems = useMemo( () => { diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss index 17ebad06c4d78e..c8f24e7efcbd2f 100644 --- a/packages/block-editor/src/components/block-list/content.scss +++ b/packages/block-editor/src/components/block-list/content.scss @@ -89,11 +89,6 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b &::after { @include selected-block-focus(); z-index: 1; - - // Show a light color for dark themes. - .is-dark-theme & { - outline-color: $dark-theme-focus; - } } } @@ -285,11 +280,6 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b &.block-editor-block-list__block:not([contenteditable]):focus { &::after { outline-color: var(--wp-block-synced-color); - - // Show a light color for dark themes. - .is-dark-theme & { - outline-color: $dark-theme-focus; - } } } } diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 6c44aa5c5d9705..15fb83139237cc 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -49,13 +49,13 @@ import { canBindBlock } from '../../../hooks/use-bindings-attributes'; * * export default function Edit() { * - * const blockProps = useBlockProps( + * const blockProps = useBlockProps( { * className: 'my-custom-class', * style: { * color: '#222222', * backgroundColor: '#eeeeee' * } - * ) + * } ) * * return ( *
diff --git a/packages/block-editor/src/components/block-list/use-in-between-inserter.js b/packages/block-editor/src/components/block-list/use-in-between-inserter.js index 74151fb3b070ba..bb307816fd1501 100644 --- a/packages/block-editor/src/components/block-list/use-in-between-inserter.js +++ b/packages/block-editor/src/components/block-list/use-in-between-inserter.js @@ -25,6 +25,7 @@ export function useInBetweenInserter() { getBlockIndex, isMultiSelecting, getSelectedBlockClientIds, + getSettings, getTemplateLock, __unstableIsWithinBlockOverlay, getBlockEditingMode, @@ -88,9 +89,11 @@ export function useInBetweenInserter() { return; } + const blockListSettings = getBlockListSettings( rootClientId ); const orientation = - getBlockListSettings( rootClientId )?.orientation || - 'vertical'; + blockListSettings?.orientation || 'vertical'; + const captureToolbars = + !! blockListSettings?.__experimentalCaptureToolbars; const offsetTop = event.clientY; const offsetLeft = event.clientX; @@ -135,9 +138,18 @@ export function useInBetweenInserter() { return; } - // Don't show the inserter when hovering above (conflicts with - // block toolbar) or inside selected block(s). - if ( getSelectedBlockClientIds().includes( clientId ) ) { + // Don't show the inserter if the following conditions are met, + // as it conflicts with the block toolbar: + // 1. when hovering above or inside selected block(s) + // 2. when the orientation is vertical + // 3. when the __experimentalCaptureToolbars is not enabled + // 4. when the Top Toolbar is not disabled + if ( + getSelectedBlockClientIds().includes( clientId ) && + orientation === 'vertical' && + ! captureToolbars && + ! getSettings().hasFixedToolbar + ) { return; } const elementRect = element.getBoundingClientRect(); diff --git a/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js index 6810a21581f12e..7ca294a2894158 100644 --- a/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js +++ b/packages/block-editor/src/components/block-settings-menu/block-mode-toggle.js @@ -4,8 +4,7 @@ import { __ } from '@wordpress/i18n'; import { MenuItem } from '@wordpress/components'; import { getBlockType, hasBlockSupport } from '@wordpress/blocks'; -import { withSelect, withDispatch } from '@wordpress/data'; -import { compose } from '@wordpress/compose'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -14,13 +13,23 @@ import { store as blockEditorStore } from '../../store'; const noop = () => {}; -export function BlockModeToggle( { - blockType, - mode, - onToggleMode, - small = false, - isCodeEditingEnabled = true, -} ) { +export default function BlockModeToggle( { clientId, onToggle = noop } ) { + const { blockType, mode, isCodeEditingEnabled } = useSelect( + ( select ) => { + const { getBlock, getBlockMode, getSettings } = + select( blockEditorStore ); + const block = getBlock( clientId ); + + return { + mode: getBlockMode( clientId ), + blockType: block ? getBlockType( block.name ) : null, + isCodeEditingEnabled: getSettings().codeEditingEnabled, + }; + }, + [ clientId ] + ); + const { toggleBlockMode } = useDispatch( blockEditorStore ); + if ( ! blockType || ! hasBlockSupport( blockType, 'html', true ) || @@ -32,26 +41,14 @@ export function BlockModeToggle( { const label = mode === 'visual' ? __( 'Edit as HTML' ) : __( 'Edit visually' ); - return { ! small && label }; + return ( + { + toggleBlockMode( clientId ); + onToggle(); + } } + > + { label } + + ); } - -export default compose( [ - withSelect( ( select, { clientId } ) => { - const { getBlock, getBlockMode, getSettings } = - select( blockEditorStore ); - const block = getBlock( clientId ); - const isCodeEditingEnabled = getSettings().codeEditingEnabled; - - return { - mode: getBlockMode( clientId ), - blockType: block ? getBlockType( block.name ) : null, - isCodeEditingEnabled, - }; - } ), - withDispatch( ( dispatch, { onToggle = noop, clientId } ) => ( { - onToggleMode() { - dispatch( blockEditorStore ).toggleBlockMode( clientId ); - onToggle(); - }, - } ) ), -] )( BlockModeToggle ); diff --git a/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js b/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js index c297bad15f29ea..67d88125e3429c 100644 --- a/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js +++ b/packages/block-editor/src/components/block-settings-menu/test/block-mode-toggle.js @@ -3,16 +3,32 @@ */ import { render, screen } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; + /** * Internal dependencies */ -import { BlockModeToggle } from '../block-mode-toggle'; +import BlockModeToggle from '../block-mode-toggle'; + +jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); + +function setupUseSelectMock( mode, blockType, codeEditingEnabled = true ) { + useSelect.mockImplementation( () => { + return { + mode, + blockType, + isCodeEditingEnabled: codeEditingEnabled, + }; + } ); +} describe( 'BlockModeToggle', () => { it( "should not render the HTML mode button if the block doesn't support it", () => { - render( - - ); + setupUseSelectMock( undefined, { supports: { html: false } } ); + render( ); expect( screen.queryByRole( 'menuitem', { name: 'Edit as HTML' } ) @@ -20,12 +36,8 @@ describe( 'BlockModeToggle', () => { } ); it( 'should render the HTML mode button', () => { - render( - - ); + setupUseSelectMock( 'visual', { supports: { html: true } } ); + render( ); expect( screen.getByRole( 'menuitem', { name: 'Edit as HTML' } ) @@ -33,12 +45,8 @@ describe( 'BlockModeToggle', () => { } ); it( 'should render the Visual mode button', () => { - render( - - ); + setupUseSelectMock( 'html', { supports: { html: true } } ); + render( ); expect( screen.getByRole( 'menuitem', { name: 'Edit visually' } ) @@ -46,13 +54,8 @@ describe( 'BlockModeToggle', () => { } ); it( 'should not render the Visual mode button if code editing is disabled', () => { - render( - - ); + setupUseSelectMock( 'html', { supports: { html: true } }, false ); + render( ); expect( screen.queryByRole( 'menuitem', { name: 'Edit visually' } ) diff --git a/packages/block-editor/src/components/block-tools/insertion-point.js b/packages/block-editor/src/components/block-tools/insertion-point.js index 9dac99e5e93124..469f7e53908cb4 100644 --- a/packages/block-editor/src/components/block-tools/insertion-point.js +++ b/packages/block-editor/src/components/block-tools/insertion-point.js @@ -38,6 +38,7 @@ function InbetweenInsertionPointPopover( { isInserterShown, isDistractionFree, isNavigationMode, + isZoomOutMode, } = useSelect( ( select ) => { const { getBlockOrder, @@ -48,6 +49,7 @@ function InbetweenInsertionPointPopover( { getNextBlockClientId, getSettings, isNavigationMode: _isNavigationMode, + __unstableGetEditorMode, } = select( blockEditorStore ); const insertionPoint = getBlockInsertionPoint(); const order = getBlockOrder( insertionPoint.rootClientId ); @@ -79,6 +81,7 @@ function InbetweenInsertionPointPopover( { isNavigationMode: _isNavigationMode(), isDistractionFree: settings.isDistractionFree, isInserterShown: insertionPoint?.__unstableWithInserter, + isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', }; }, [] ); const { getBlockEditingMode } = useSelect( blockEditorStore ); @@ -145,6 +148,14 @@ function InbetweenInsertionPointPopover( { return null; } + // Zoom out mode should only show the insertion point for the insert operation. + // Other operations such as "group" are when the editor tries to create a row + // block by grouping the block being dragged with the block it's being dropped + // onto. + if ( isZoomOutMode && operation !== 'insert' ) { + return null; + } + const orientationClassname = orientation === 'horizontal' || operation === 'group' ? 'is-horizontal' diff --git a/packages/block-editor/src/components/block-tools/use-show-block-tools.js b/packages/block-editor/src/components/block-tools/use-show-block-tools.js index 33807445b8da74..07e0ebd16a64b0 100644 --- a/packages/block-editor/src/components/block-tools/use-show-block-tools.js +++ b/packages/block-editor/src/components/block-tools/use-show-block-tools.js @@ -20,6 +20,7 @@ export function useShowBlockTools() { getSelectedBlockClientId, getFirstMultiSelectedBlockClientId, getBlock, + getBlockMode, getSettings, hasMultiSelection, __unstableGetEditorMode, @@ -33,7 +34,9 @@ export function useShowBlockTools() { const editorMode = __unstableGetEditorMode(); const hasSelectedBlock = !! clientId && !! block; const isEmptyDefaultBlock = - hasSelectedBlock && isUnmodifiedDefaultBlock( block ); + hasSelectedBlock && + isUnmodifiedDefaultBlock( block ) && + getBlockMode( clientId ) !== 'html'; const _showEmptyBlockSideInserter = clientId && ! isTyping() && diff --git a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js index bb044f9479c024..6f986ce90dc3bd 100644 --- a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js +++ b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserters.js @@ -23,6 +23,7 @@ function ZoomOutModeInserters() { sectionRootClientId, selectedBlockClientId, hoveredBlockClientId, + inserterSearchInputRef, } = useSelect( ( select ) => { const { getSettings, @@ -32,8 +33,11 @@ function ZoomOutModeInserters() { getSelectedBlockClientId, getHoveredBlockClientId, isBlockInsertionPointVisible, - } = select( blockEditorStore ); + getInserterSearchInputRef, + } = unlock( select( blockEditorStore ) ); + const { sectionRootClientId: root } = unlock( getSettings() ); + return { hasSelection: !! getSelectionStart().clientId, blockInsertionPoint: getBlockInsertionPoint(), @@ -44,6 +48,7 @@ function ZoomOutModeInserters() { getSettings().__experimentalSetIsInserterOpened, selectedBlockClientId: getSelectedBlockClientId(), hoveredBlockClientId: getHoveredBlockClientId(), + inserterSearchInputRef: getInserterSearchInputRef(), }; }, [] ); @@ -110,6 +115,7 @@ function ZoomOutModeInserters() { showInsertionPoint( sectionRootClientId, index, { operation: 'insert', } ); + inserterSearchInputRef?.current?.focus(); } } /> ) } diff --git a/packages/block-editor/src/components/border-radius-control/index.js b/packages/block-editor/src/components/border-radius-control/index.js index 4c614084a7e200..cab9b87b3b29c0 100644 --- a/packages/block-editor/src/components/border-radius-control/index.js +++ b/packages/block-editor/src/components/border-radius-control/index.js @@ -104,6 +104,7 @@ export default function BorderRadiusControl( { onChange, values } ) { units={ units } /> div { - height: 40px; - display: flex; - align-items: center; - } - } - - > span { - flex: 0 0 auto; } } diff --git a/packages/block-editor/src/components/date-format-picker/index.js b/packages/block-editor/src/components/date-format-picker/index.js index 15beec4ac6ed54..edefd6249f1aae 100644 --- a/packages/block-editor/src/components/date-format-picker/index.js +++ b/packages/block-editor/src/components/date-format-picker/index.js @@ -129,6 +129,7 @@ function NonDefaultControls( { format, onChange } ) { return ( { isCustom && ( { */ export default function FontAppearanceControl( props ) { const { + /** Start opting into the larger default height that will become the default size in a future version. */ + __next40pxDefaultSize = false, onChange, hasFontStyles = true, hasFontWeights = true, @@ -150,6 +152,7 @@ export default function FontAppearanceControl( props ) { ` instance. +#### `__next40pxDefaultSize` + +- Type: `boolean` +- Required: No +- Default: `false` + +Start opting into the larger default height that will become the default size in a future version. + #### `__nextHasNoMarginBottom` -- **Type:** `boolean` -- **Default:** `false` +- Type: `boolean` +- Required: No +- Default: `false` Start opting into the new margin-free styles that will become the default in a future version. diff --git a/packages/block-editor/src/components/font-family/index.js b/packages/block-editor/src/components/font-family/index.js index 90a0412463b3ef..c87a52b4c676d2 100644 --- a/packages/block-editor/src/components/font-family/index.js +++ b/packages/block-editor/src/components/font-family/index.js @@ -11,6 +11,8 @@ import { __ } from '@wordpress/i18n'; import { useSettings } from '../use-settings'; export default function FontFamilyControl( { + /** Start opting into the larger default height that will become the default size in a future version. */ + __next40pxDefaultSize = false, /** Start opting into the new margin-free styles that will become the default in a future version. */ __nextHasNoMarginBottom = false, value = '', @@ -50,6 +52,7 @@ export default function FontFamilyControl( { return ( + + + ); +} + function BackgroundImageControls( { onChange, style, inheritedValue, onRemoveImage = noop, + onResetImage = noop, displayInPanel, - themeFileURIs, + defaultValues, } ) { - const mediaUpload = useSelect( - ( select ) => select( blockEditorStore ).getSettings().mediaUpload, - [] - ); + const [ isUploading, setIsUploading ] = useState( false ); + const { getSettings } = useSelect( blockEditorStore ); const { id, title, url } = style?.background?.backgroundImage || { ...inheritedValue?.background?.backgroundImage, @@ -283,6 +297,7 @@ function BackgroundImageControls( { const { createErrorNotice } = useDispatch( noticesStore ); const onUploadError = ( message ) => { createErrorNotice( message, { type: 'snackbar' } ); + setIsUploading( false ); }; const resetBackgroundImage = () => @@ -297,10 +312,12 @@ function BackgroundImageControls( { const onSelectMedia = ( media ) => { if ( ! media || ! media.url ) { resetBackgroundImage(); + setIsUploading( false ); return; } if ( isBlobURL( media.url ) ) { + setIsUploading( true ); return; } @@ -319,12 +336,8 @@ function BackgroundImageControls( { } const sizeValue = - style?.background?.backgroundSize || - inheritedValue?.background?.backgroundSize; - const positionValue = - style?.background?.backgroundPosition || - inheritedValue?.background?.backgroundPosition; - + style?.background?.backgroundSize || defaultValues?.backgroundSize; + const positionValue = style?.background?.backgroundPosition; onChange( setImmutably( style, [ 'background' ], { ...style?.background, @@ -335,22 +348,33 @@ function BackgroundImageControls( { title: media.title || undefined, }, backgroundPosition: + /* + * A background image uploaded and set in the editor receives a default background position of '50% 0', + * when the background image size is the equivalent of "Tile". + * This is to increase the chance that the image's focus point is visible. + * This is in-editor only to assist with the user experience. + */ ! positionValue && ( 'auto' === sizeValue || ! sizeValue ) ? '50% 0' : positionValue, backgroundSize: sizeValue, } ) ); + setIsUploading( false ); }; + // Drag and drop callback, restricting image to one. const onFilesDrop = ( filesList ) => { - mediaUpload( { + if ( filesList?.length > 1 ) { + onUploadError( + __( 'Only one image can be used as a background image.' ) + ); + return; + } + getSettings().mediaUpload( { allowedTypes: [ IMAGE_BACKGROUND_TYPE ], filesList, onFileChange( [ image ] ) { - if ( isBlobURL( image?.url ) ) { - return; - } onSelectMedia( image ); }, onError: onUploadError, @@ -372,7 +396,9 @@ function BackgroundImageControls( { const onRemove = () => onChange( - setImmutably( style, [ 'background', 'backgroundImage' ], 'none' ) + setImmutably( style, [ 'background' ], { + backgroundImage: 'none', + } ) ); const canRemove = ! hasValue && hasBackgroundImageValue( inheritedValue ); const imgLabel = @@ -383,6 +409,7 @@ function BackgroundImageControls( { ref={ replaceContainerRef } className="block-editor-global-styles-background-panel__image-tools-panel-item" > + { isUploading && } } variant="secondary" + onError={ onUploadError } > { canRemove && ( { closeAndFocus(); onRemove(); + onRemoveImage(); } } > { __( 'Remove' ) } @@ -422,7 +448,7 @@ function BackgroundImageControls( { { closeAndFocus(); - onRemoveImage(); + onResetImage(); } } > { __( 'Reset ' ) } @@ -442,7 +468,6 @@ function BackgroundSizeControls( { style, inheritedValue, defaultValues, - themeFileURIs, } ) { const sizeValue = style?.background?.backgroundSize || @@ -453,9 +478,7 @@ function BackgroundSizeControls( { const imageValue = style?.background?.backgroundImage?.url || inheritedValue?.background?.backgroundImage?.url; - const isUploadedImage = - style?.background?.backgroundImage?.id || - inheritedValue?.background?.backgroundImage?.id; + const isUploadedImage = style?.background?.backgroundImage?.id; const positionValue = style?.background?.backgroundPosition || inheritedValue?.background?.backgroundPosition; @@ -469,11 +492,19 @@ function BackgroundSizeControls( { * Block-level controls may have different defaults to root-level controls. * A falsy value is treated by default as `auto` (Tile). */ - const currentValueForToggle = + let currentValueForToggle = ! sizeValue && isUploadedImage ? defaultValues?.backgroundSize : sizeValue || 'auto'; - + /* + * The incoming value could be a value + unit, e.g. '20px'. + * In this case set the value to 'tile'. + */ + currentValueForToggle = ! [ 'cover', 'contain', 'auto' ].includes( + currentValueForToggle + ) + ? 'auto' + : currentValueForToggle; /* * If the current value is `cover` and the repeat value is `undefined`, then * the toggle should be unchecked as the default state. Otherwise, the toggle @@ -510,6 +541,7 @@ function BackgroundSizeControls( { * receives a default background position of '50% 0', * when the toggle switches to "Tile". This is to increase the chance that * the image's focus point is visible. + * This is in-editor only to assist with the user experience. */ if ( !! style?.background?.backgroundImage?.id ) { nextPosition = '50% 0'; @@ -562,14 +594,19 @@ function BackgroundSizeControls( { ) ); + // Set a default background position for non-site-wide, uploaded images with a size of 'contain'. + const backgroundPositionValue = + ! positionValue && isUploadedImage && 'contain' === sizeValue + ? defaultValues?.backgroundPosition + : positionValue; + return ( - + { + const { getSettings } = select( blockEditorStore ); + const _settings = getSettings(); + return { + globalStyles: _settings[ globalStylesDataKey ], + _links: _settings[ globalStylesLinksDataKey ], + }; + }, [] ); + const resolvedInheritedValue = useMemo( () => { + const resolvedValues = { + background: {}, + }; + + if ( ! inheritedValue?.background ) { + return inheritedValue; + } + + Object.entries( inheritedValue?.background ).forEach( + ( [ key, backgroundValue ] ) => { + resolvedValues.background[ key ] = getResolvedValue( + backgroundValue, + { + styles: globalStyles, + _links, + } + ); + } + ); + return resolvedValues; + }, [ globalStyles, _links, inheritedValue ] ); + const resetAllFilter = useCallback( ( previousValue ) => { return { ...previousValue, @@ -694,14 +764,19 @@ export default function BackgroundPanel( { onChange( setImmutably( value, [ 'background' ], {} ) ); const { title, url } = value?.background?.backgroundImage || { - ...inheritedValue?.background?.backgroundImage, + ...resolvedInheritedValue?.background?.backgroundImage, }; const hasImageValue = hasBackgroundImageValue( value ) || - hasBackgroundImageValue( inheritedValue ); + hasBackgroundImageValue( resolvedInheritedValue ); + + const imageValue = + value?.background?.backgroundImage || + inheritedValue?.background?.backgroundImage; const shouldShowBackgroundImageControls = hasImageValue && + 'none' !== imageValue && ( settings?.background?.backgroundSize || settings?.background?.backgroundPosition || settings?.background?.backgroundRepeat ); @@ -725,7 +800,7 @@ export default function BackgroundPanel( { ) } > hasImageValue } + hasValue={ () => !! value?.background } label={ __( 'Image' ) } onDeselect={ resetBackground } isShownByDefault={ defaultControls.backgroundImage } @@ -735,10 +810,7 @@ export default function BackgroundPanel( { @@ -746,21 +818,23 @@ export default function BackgroundPanel( { { + onResetImage={ () => { setIsDropDownOpen( false ); resetBackground(); } } + onRemoveImage={ () => + setIsDropDownOpen( false ) + } + defaultValues={ defaultValues } /> @@ -768,8 +842,13 @@ export default function BackgroundPanel( { { + setIsDropDownOpen( false ); + resetBackground(); + } } + onRemoveImage={ () => setIsDropDownOpen( false ) } /> ) } diff --git a/packages/block-editor/src/components/global-styles/hooks.js b/packages/block-editor/src/components/global-styles/hooks.js index a1a4fc1a0a6ae1..2be77aec18a2c6 100644 --- a/packages/block-editor/src/components/global-styles/hooks.js +++ b/packages/block-editor/src/components/global-styles/hooks.js @@ -209,11 +209,6 @@ export function useGlobalStyle( return [ result, setStyle ]; } -export function useGlobalStyleLinks() { - const { merged: mergedConfig } = useContext( GlobalStylesContext ); - return mergedConfig?._links; -} - /** * React hook that overrides a global settings object with block and element specific settings. * diff --git a/packages/block-editor/src/components/global-styles/index.js b/packages/block-editor/src/components/global-styles/index.js index 062df0a5606e90..8096a48569f199 100644 --- a/packages/block-editor/src/components/global-styles/index.js +++ b/packages/block-editor/src/components/global-styles/index.js @@ -3,7 +3,6 @@ export { useGlobalSetting, useGlobalStyle, useSettingsForBlockElement, - useGlobalStyleLinks, } from './hooks'; export { getBlockCSSSelector } from './get-block-css-selector'; export { diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index b8dd3700a77f88..acd5fd6f41c7bc 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -134,6 +134,17 @@ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); } } + + .block-editor-global-styles-background-panel__loading { + height: 100%; + position: absolute; + z-index: 1; + width: 100%; + padding: 10px 0 0 0; + svg { + margin: 0; + } + } } .block-editor-global-styles-background-panel__image-preview-content, diff --git a/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js b/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js deleted file mode 100644 index e239bb0941ea9e..00000000000000 --- a/packages/block-editor/src/components/global-styles/test/theme-file-uri-utils.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Internal dependencies - */ -import { getResolvedThemeFilePath } from '../theme-file-uri-utils'; - -const themeFileURIs = [ - { - name: 'file:./assets/image.jpg', - href: 'https://wordpress.org/assets/image.jpg', - target: 'styles.background.backgroundImage.url', - }, - { - name: 'file:./assets/other/image.jpg', - href: 'https://wordpress.org/assets/other/image.jpg', - target: "styles.blocks.['core/group].background.backgroundImage.url", - }, -]; - -describe( 'getResolvedThemeFilePath()', () => { - it.each( [ - [ - 'file:./assets/image.jpg', - 'https://wordpress.org/assets/image.jpg', - 'Should return absolute URL if found in themeFileURIs', - ], - [ - 'file:./misc/image.jpg', - 'file:./misc/image.jpg', - 'Should return value if not found in themeFileURIs', - ], - [ - 'https://wordpress.org/assets/image.jpg', - 'https://wordpress.org/assets/image.jpg', - 'Should not match absolute URLs', - ], - ] )( 'Given file %s and return value %s: %s', ( file, returnedValue ) => { - expect( - getResolvedThemeFilePath( file, themeFileURIs ) === returnedValue - ).toBe( true ); - } ); -} ); diff --git a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js index f648e1db845b87..5022e8ba591dbb 100644 --- a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js @@ -763,7 +763,7 @@ describe( 'global styles renderer', () => { } ); expect( layoutStyles ).toEqual( - '.is-layout-flow > * { margin-block-start: 0; margin-block-end: 0; }.is-layout-flow > * + * { margin-block-start: 0.5em; margin-block-end: 0; }.is-layout-flex { gap: 0.5em; }:root { --wp--style--block-gap: 0.5em; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' + ':root :where(.is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.is-layout-flow) > * + * { margin-block-start: 0.5em; margin-block-end: 0; }:root :where(.is-layout-flex) { gap: 0.5em; }:root { --wp--style--block-gap: 0.5em; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' ); } ); @@ -780,7 +780,7 @@ describe( 'global styles renderer', () => { } ); expect( layoutStyles ).toEqual( - '.is-layout-flow > * { margin-block-start: 0; margin-block-end: 0; }.is-layout-flow > * + * { margin-block-start: 12px; margin-block-end: 0; }.is-layout-flex { gap: 12px; }:root { --wp--style--block-gap: 12px; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' + ':root :where(.is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.is-layout-flow) > * + * { margin-block-start: 12px; margin-block-end: 0; }:root :where(.is-layout-flex) { gap: 12px; }:root { --wp--style--block-gap: 12px; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' ); } ); @@ -797,7 +797,7 @@ describe( 'global styles renderer', () => { } ); expect( layoutStyles ).toEqual( - '.wp-block-group-is-layout-flow > * { margin-block-start: 0; margin-block-end: 0; }.wp-block-group-is-layout-flow > * + * { margin-block-start: 12px; margin-block-end: 0; }.wp-block-group-is-layout-flex { gap: 12px; }' + ':root :where(.wp-block-group-is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.wp-block-group-is-layout-flow) > * + * { margin-block-start: 12px; margin-block-end: 0; }:root :where(.wp-block-group-is-layout-flex) { gap: 12px; }' ); } ); @@ -1008,9 +1008,23 @@ describe( 'global styles renderer', () => { ref: 'styles.elements.h1.typography.letterSpacing', }, }, + background: { + backgroundImage: { + ref: 'styles.background.backgroundImage', + }, + backgroundSize: { + ref: 'styles.background.backgroundSize', + }, + }, }; const tree = { styles: { + background: { + backgroundImage: { + url: 'http://my-image.org/image.gif', + }, + backgroundSize: 'cover', + }, elements: { h1: { typography: { @@ -1026,6 +1040,8 @@ describe( 'global styles renderer', () => { ).toEqual( [ 'font-size: var(--wp--preset--font-size--xx-large)', 'letter-spacing: 2px', + "background-image: url( 'http://my-image.org/image.gif' )", + 'background-size: cover', ] ); } ); it( 'should set default values for block background styles', () => { @@ -1061,7 +1077,7 @@ describe( 'global styles renderer', () => { ) ).toEqual( [ "background-image: url( 'https://wordpress.org/assets/image.jpg' )", - 'background-position: center', + 'background-position: 50% 50%', 'background-size: contain', ] ); } ); diff --git a/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js b/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js deleted file mode 100644 index 96b3e2e4cb68b0..00000000000000 --- a/packages/block-editor/src/components/global-styles/theme-file-uri-utils.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Looks up a theme file URI based on a relative path. - * - * @param {string} file A relative path. - * @param {Array} themeFileURIs A collection of absolute theme file URIs and their corresponding file paths. - * @return {string?} A resolved theme file URI, if one is found in the themeFileURIs collection. - */ -export function getResolvedThemeFilePath( file, themeFileURIs = [] ) { - const uri = themeFileURIs.find( - ( themeFileUri ) => themeFileUri.name === file - ); - - if ( ! uri?.href ) { - return file; - } - - return uri?.href; -} diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index 9190733d5b6607..cd4ad0cea50e0d 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -10,7 +10,7 @@ import { } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; import { useContext, useMemo } from '@wordpress/element'; -import { getCSSRules } from '@wordpress/style-engine'; +import { getCSSRules, getCSSValueFromRawStyle } from '@wordpress/style-engine'; import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** @@ -24,7 +24,6 @@ import { scopeFeatureSelectors, appendToSelector, getBlockStyleVariationSelector, - compileStyleValue, getResolvedValue, } from './utils'; import { getBlockCSSSelector } from './get-block-css-selector'; @@ -357,7 +356,7 @@ export function getStylesDeclarations( ? name : kebabCase( name ); declarations.push( - `${ cssProperty }: ${ compileStyleValue( + `${ cssProperty }: ${ getCSSValueFromRawStyle( getValueFromObjectPath( styleValue, [ prop ] ) ) }` ); @@ -369,7 +368,7 @@ export function getStylesDeclarations( ? key : kebabCase( key ); declarations.push( - `${ cssProperty }: ${ compileStyleValue( + `${ cssProperty }: ${ getCSSValueFromRawStyle( getValueFromObjectPath( blockStyles, pathToValue ) ) }` ); @@ -538,10 +537,10 @@ export function getLayoutStyles( { } else { combinedSelector = selector === ROOT_BLOCK_SELECTOR - ? `.${ className }${ + ? `:root :where(.${ className })${ spacingStyle?.selector || '' }` - : `${ selector }-${ className }${ + : `:root :where(${ selector }-${ className })${ spacingStyle?.selector || '' }`; } diff --git a/packages/block-editor/src/components/global-styles/utils.js b/packages/block-editor/src/components/global-styles/utils.js index 8de479e39382e5..59799c9032c67f 100644 --- a/packages/block-editor/src/components/global-styles/utils.js +++ b/packages/block-editor/src/components/global-styles/utils.js @@ -7,6 +7,7 @@ import fastDeepEqual from 'fast-deep-equal/es6'; * WordPress dependencies */ import { useViewportMatch } from '@wordpress/compose'; +import { getCSSValueFromRawStyle } from '@wordpress/style-engine'; /** * Internal dependencies @@ -526,34 +527,6 @@ export function getBlockStyleVariationSelector( variation, blockSelector ) { return result.join( ',' ); } -/** - * Converts style preset values `var:` to CSS custom var values. - * TODO: Export and use the style engine util: getCSSVarFromStyleValue(). - * - * Example: - * - * compileStyleValue( 'var:preset|color|primary' ) // returns 'var(--wp--color-primary)' - * - * @param {string} uncompiledValue A block style value. - * @return {string} The compiled, or original value. - */ -export function compileStyleValue( uncompiledValue ) { - const VARIABLE_REFERENCE_PREFIX = 'var:'; - if ( - 'string' === typeof uncompiledValue && - uncompiledValue?.startsWith?.( VARIABLE_REFERENCE_PREFIX ) - ) { - const VARIABLE_PATH_SEPARATOR_TOKEN_ATTRIBUTE = '|'; - const VARIABLE_PATH_SEPARATOR_TOKEN_STYLE = '--'; - const variable = uncompiledValue - .slice( VARIABLE_REFERENCE_PREFIX.length ) - .split( VARIABLE_PATH_SEPARATOR_TOKEN_ATTRIBUTE ) - .join( VARIABLE_PATH_SEPARATOR_TOKEN_STYLE ); - return `var(--wp--${ variable })`; - } - return uncompiledValue; -} - /** * Looks up a theme file URI based on a relative path. * @@ -589,9 +562,14 @@ export function getResolvedRefValue( ruleValue, tree ) { return ruleValue; } + /* + * Where the rule value is an object with a 'ref' property pointing + * to a path, this converts that path into the value at that path. + * For example: { "ref": "style.color.background" } => "#fff". + */ if ( typeof ruleValue !== 'string' && ruleValue?.ref ) { const refPath = ruleValue.ref.split( '.' ); - const resolvedRuleValue = compileStyleValue( + const resolvedRuleValue = getCSSValueFromRawStyle( getValueFromObjectPath( tree, refPath ) ); diff --git a/packages/block-editor/src/components/grid/grid-visualizer.js b/packages/block-editor/src/components/grid/grid-visualizer.js index e1d35f012b4d81..81da0457ffc5ca 100644 --- a/packages/block-editor/src/components/grid/grid-visualizer.js +++ b/packages/block-editor/src/components/grid/grid-visualizer.js @@ -19,6 +19,7 @@ import { range, GridRect, getGridInfo } from './utils'; import { store as blockEditorStore } from '../../store'; import { useGetNumberOfBlocksBeforeCell } from './use-get-number-of-blocks-before-cell'; import ButtonBlockAppender from '../button-block-appender'; +import { unlock } from '../../lock-unlock'; export function GridVisualizer( { clientId, contentRef, parentLayout } ) { const isDistractionFree = useSelect( @@ -118,19 +119,25 @@ const GridVisualizerGrid = forwardRef( function ManualGridVisualizer( { gridClientId, gridInfo } ) { const [ highlightedRect, setHighlightedRect ] = useState( null ); - const gridItems = useSelect( - ( select ) => select( blockEditorStore ).getBlocks( gridClientId ), + const gridItemStyles = useSelect( + ( select ) => { + const { getBlockOrder, getBlockStyles } = unlock( + select( blockEditorStore ) + ); + const blockOrder = getBlockOrder( gridClientId ); + return getBlockStyles( blockOrder ); + }, [ gridClientId ] ); const occupiedRects = useMemo( () => { const rects = []; - for ( const block of gridItems ) { + for ( const style of Object.values( gridItemStyles ) ) { const { columnStart, rowStart, columnSpan = 1, rowSpan = 1, - } = block.attributes.style?.layout || {}; + } = style?.layout ?? {}; if ( ! columnStart || ! rowStart ) { continue; } @@ -144,7 +151,7 @@ function ManualGridVisualizer( { gridClientId, gridInfo } ) { ); } return rects; - }, [ gridItems ] ); + }, [ gridItemStyles ] ); return range( 1, gridInfo.numRows ).map( ( row ) => range( 1, gridInfo.numColumns ).map( ( column ) => { @@ -206,8 +213,12 @@ function useGridVisualizerDropZone( gridInfo, setHighlightedRect ) { - const { getBlockAttributes, getBlockRootClientId } = - useSelect( blockEditorStore ); + const { + getBlockAttributes, + getBlockRootClientId, + canInsertBlockType, + getBlockName, + } = useSelect( blockEditorStore ); const { updateBlockAttributes, moveBlocksToPosition, @@ -221,6 +232,10 @@ function useGridVisualizerDropZone( return useDropZoneWithValidation( { validateDrag( srcClientId ) { + const blockName = getBlockName( srcClientId ); + if ( ! canInsertBlockType( blockName, gridClientId ) ) { + return false; + } const attributes = getBlockAttributes( srcClientId ); const rect = new GridRect( { columnStart: column, diff --git a/packages/block-editor/src/components/height-control/index.js b/packages/block-editor/src/components/height-control/index.js index 71753a67beb021..5d42e217776d6a 100644 --- a/packages/block-editor/src/components/height-control/index.js +++ b/packages/block-editor/src/components/height-control/index.js @@ -164,6 +164,7 @@ export default function HeightControl( { { - prevContainerWidth.current = containerWidth; - }, [ containerWidth ] ); + if ( ! isZoomedOut ) { + prevContainerWidth.current = containerWidth; + } + }, [ containerWidth, isZoomedOut ] ); const disabledRef = useDisabled( { isDisabled: ! readonly } ); const bodyRef = useMergeRefs( [ diff --git a/packages/block-editor/src/components/image-editor/zoom-dropdown.js b/packages/block-editor/src/components/image-editor/zoom-dropdown.js index 70baa28b2e112c..5093b11feb6d87 100644 --- a/packages/block-editor/src/components/image-editor/zoom-dropdown.js +++ b/packages/block-editor/src/components/image-editor/zoom-dropdown.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { ToolbarButton, RangeControl, Dropdown } from '@wordpress/components'; +import { + ToolbarButton, + RangeControl, + Dropdown, + __experimentalDropdownContentWrapper as DropdownContentWrapper, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { search } from '@wordpress/icons'; @@ -27,14 +32,17 @@ export default function ZoomDropdown() { /> ) } renderContent={ () => ( - + + + ) } /> ); diff --git a/packages/block-editor/src/components/inner-blocks/button-block-appender.js b/packages/block-editor/src/components/inner-blocks/button-block-appender.js index 500e59863db429..5bc788d58582f6 100644 --- a/packages/block-editor/src/components/inner-blocks/button-block-appender.js +++ b/packages/block-editor/src/components/inner-blocks/button-block-appender.js @@ -7,15 +7,15 @@ import clsx from 'clsx'; * Internal dependencies */ import BaseButtonBlockAppender from '../button-block-appender'; -import withClientId from './with-client-id'; +import { useBlockEditContext } from '../block-edit/context'; -export const ButtonBlockAppender = ( { - clientId, +export default function ButtonBlockAppender( { showSeparator, isFloating, onAddBlock, isToggle, -} ) => { +} ) { + const { clientId } = useBlockEditContext(); return ( ); -}; - -export default withClientId( ButtonBlockAppender ); +} diff --git a/packages/block-editor/src/components/inner-blocks/default-block-appender.js b/packages/block-editor/src/components/inner-blocks/default-block-appender.js index d2e137004d83bf..91e48a2854b513 100644 --- a/packages/block-editor/src/components/inner-blocks/default-block-appender.js +++ b/packages/block-editor/src/components/inner-blocks/default-block-appender.js @@ -1,29 +1,10 @@ -/** - * WordPress dependencies - */ -import { compose } from '@wordpress/compose'; -import { withSelect } from '@wordpress/data'; - /** * Internal dependencies */ import BaseDefaultBlockAppender from '../default-block-appender'; -import withClientId from './with-client-id'; -import { store as blockEditorStore } from '../../store'; +import { useBlockEditContext } from '../block-edit/context'; -export const DefaultBlockAppender = ( { clientId } ) => { +export default function DefaultBlockAppender() { + const { clientId } = useBlockEditContext(); return ; -}; - -export default compose( [ - withClientId, - withSelect( ( select, { clientId } ) => { - const { getBlockOrder } = select( blockEditorStore ); - - const blockClientIds = getBlockOrder( clientId ); - - return { - lastBlockClientId: blockClientIds[ blockClientIds.length - 1 ], - }; - } ), -] )( DefaultBlockAppender ); +} diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 27e5064eeb6328..c8db9f8cebf905 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -206,13 +206,7 @@ export function useInnerBlocksProps( props = {}, options = {} ) { getSettings, } = unlock( select( blockEditorStore ) ); let _isDropZoneDisabled; - // In zoom out mode, we want to disable the drop zone for the sections. - // The inner blocks belonging to the section drop zone is - // already disabled by the blocks themselves being disabled. - if ( __unstableGetEditorMode() === 'zoom-out' ) { - const { sectionRootClientId } = unlock( getSettings() ); - _isDropZoneDisabled = clientId !== sectionRootClientId; - } + if ( ! clientId ) { return { isDropZoneDisabled: _isDropZoneDisabled }; } @@ -225,8 +219,15 @@ export function useInnerBlocksProps( props = {}, options = {} ) { const parentClientId = getBlockRootClientId( clientId ); const [ defaultLayout ] = getBlockSettings( clientId, 'layout' ); - if ( _isDropZoneDisabled !== undefined ) { - _isDropZoneDisabled = blockEditingMode === 'disabled'; + _isDropZoneDisabled = blockEditingMode === 'disabled'; + + if ( __unstableGetEditorMode() === 'zoom-out' ) { + // In zoom out mode, we want to disable the drop zone for the sections. + // The inner blocks belonging to the section drop zone is + // already disabled by the blocks themselves being disabled. + const { sectionRootClientId } = unlock( getSettings() ); + + _isDropZoneDisabled = clientId !== sectionRootClientId; } return { diff --git a/packages/block-editor/src/components/inner-blocks/with-client-id.js b/packages/block-editor/src/components/inner-blocks/with-client-id.js deleted file mode 100644 index 97c73ae2803934..00000000000000 --- a/packages/block-editor/src/components/inner-blocks/with-client-id.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * WordPress dependencies - */ -import { createHigherOrderComponent } from '@wordpress/compose'; - -/** - * Internal dependencies - */ -import { useBlockEditContext } from '../block-edit/context'; - -const withClientId = createHigherOrderComponent( - ( WrappedComponent ) => ( props ) => { - const { clientId } = useBlockEditContext(); - return ; - }, - 'withClientId' -); - -export default withClientId; diff --git a/packages/block-editor/src/components/inserter/library.js b/packages/block-editor/src/components/inserter/library.js index 4e10a051996a9f..fe14d48bb4016b 100644 --- a/packages/block-editor/src/components/inserter/library.js +++ b/packages/block-editor/src/components/inserter/library.js @@ -27,6 +27,7 @@ function InserterLibrary( onSelect = noop, shouldFocusBlock = false, onClose, + __experimentalSearchInputRef, }, ref ) { @@ -58,6 +59,7 @@ function InserterLibrary( shouldFocusBlock={ shouldFocusBlock } ref={ ref } onClose={ onClose } + __experimentalSearchInputRef={ __experimentalSearchInputRef } /> ); } diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index e0bc29d62e1b9a..629765315c1d6b 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -33,6 +33,7 @@ import useInsertionPoint from './hooks/use-insertion-point'; import { store as blockEditorStore } from '../../store'; import TabbedSidebar from '../tabbed-sidebar'; import { useZoomOut } from '../../hooks/use-zoom-out'; +import { unlock } from '../../lock-unlock'; const NOOP = () => {}; function InserterMenu( @@ -53,11 +54,16 @@ function InserterMenu( }, ref ) { - const isZoomOutMode = useSelect( - ( select ) => - select( blockEditorStore ).__unstableGetEditorMode() === 'zoom-out', - [] - ); + const { isZoomOutMode, inserterSearchInputRef } = useSelect( ( select ) => { + const { __unstableGetEditorMode, getInserterSearchInputRef } = unlock( + select( blockEditorStore ) + ); + return { + isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', + inserterSearchInputRef: getInserterSearchInputRef(), + }; + }, [] ); + const [ filterValue, setFilterValue, delayedFilterValue ] = useDebouncedInput( __experimentalFilterValue ); const [ hoveredItem, setHoveredItem ] = useState( null ); @@ -67,9 +73,16 @@ function InserterMenu( const [ patternFilter, setPatternFilter ] = useState( 'all' ); const [ selectedMediaCategory, setSelectedMediaCategory ] = useState( null ); - const [ selectedTab, setSelectedTab ] = useState( - __experimentalInitialTab - ); + function getInitialTab() { + if ( __experimentalInitialTab ) { + return __experimentalInitialTab; + } + + if ( isZoomOutMode ) { + return 'patterns'; + } + } + const [ selectedTab, setSelectedTab ] = useState( getInitialTab() ); const [ destinationRootClientId, onInsertBlocks, onToggleInsertionPoint ] = useInsertionPoint( { @@ -104,15 +117,16 @@ function InserterMenu( } } ); }, - [ onInsertBlocks, onSelect, shouldFocusBlock ] + [ onInsertBlocks, onSelect, ref, shouldFocusBlock ] ); const onInsertPattern = useCallback( ( blocks, patternName ) => { + onToggleInsertionPoint( false ); onInsertBlocks( blocks, { patternName } ); onSelect(); }, - [ onInsertBlocks, onSelect ] + [ onInsertBlocks, onSelect, onToggleInsertionPoint ] ); const onHover = useCallback( @@ -123,13 +137,6 @@ function InserterMenu( [ onToggleInsertionPoint, setHoveredItem ] ); - const onHoverPattern = useCallback( - ( item ) => { - onToggleInsertionPoint( !! item ); - }, - [ onToggleInsertionPoint ] - ); - const onClickPatternCategory = useCallback( ( patternCategory, filter ) => { setSelectedPatternCategory( patternCategory ); @@ -170,13 +177,14 @@ function InserterMenu( value={ filterValue } label={ __( 'Search for blocks and patterns' ) } placeholder={ __( 'Search' ) } + ref={ inserterSearchInputRef } /> + { !! delayedFilterValue && ( { @@ -249,7 +256,6 @@ function InserterMenu( { @@ -91,6 +93,7 @@ export default function QuickInserter( { filterValue, onSelect, } ); + showInsertionPoint( rootClientId, insertionIndex ); }; let maxBlockPatterns = 0; diff --git a/packages/block-editor/src/components/line-height-control/README.md b/packages/block-editor/src/components/line-height-control/README.md index dafad9145022b9..89bcc69622367f 100644 --- a/packages/block-editor/src/components/line-height-control/README.md +++ b/packages/block-editor/src/components/line-height-control/README.md @@ -36,6 +36,13 @@ The value of the line height. A callback function that handles the application of the line height value. +#### `__next40pxDefaultSize` + +- **Type:** `boolean` +- **Default:** `false` + +Start opting into the larger default height that will become the default size in a future version. + ## Related components Block Editor components are components that can be used to compose the UI of your block editor. Thus, they can only be used under a [`BlockEditorProvider`](https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/provider/README.md) in the components tree. diff --git a/packages/block-editor/src/components/line-height-control/index.js b/packages/block-editor/src/components/line-height-control/index.js index d605aea3d2ef18..b2c99c03f87840 100644 --- a/packages/block-editor/src/components/line-height-control/index.js +++ b/packages/block-editor/src/components/line-height-control/index.js @@ -16,6 +16,8 @@ import { } from './utils'; const LineHeightControl = ( { + /** Start opting into the larger default height that will become the default size in a future version. */ + __next40pxDefaultSize = false, value: lineHeight, onChange, __unstableInputWidth = '60px', @@ -91,6 +93,7 @@ const LineHeightControl = ( {
{ return ( { return ( <> - - { label } - -
- { options.map( ( option ) => { - return ( -
- - ); -} diff --git a/packages/block-editor/src/components/segmented-text-control/style.scss b/packages/block-editor/src/components/segmented-text-control/style.scss deleted file mode 100644 index 7a4a3bbea7cb33..00000000000000 --- a/packages/block-editor/src/components/segmented-text-control/style.scss +++ /dev/null @@ -1,15 +0,0 @@ -.block-editor-segmented-text-control { - border: 0; - margin: 0; - padding: 0; - - .block-editor-segmented-text-control__buttons { - // 4px of padding makes the row 40px high, same as an input. - padding: $grid-unit-05 0; - display: flex; - } - - .components-button.has-icon { - margin-right: $grid-unit-05; - } -} diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js index 4faf05ba254089..d00feed704d17a 100644 --- a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js +++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js @@ -188,10 +188,12 @@ export default function SpacingInputControl( { name: size.name, } ) ); - const marks = spacingSizes.map( ( _newValue, index ) => ( { - value: index, - label: undefined, - } ) ); + const marks = spacingSizes + .slice( 1, spacingSizes.length - 1 ) + .map( ( _newValue, index ) => ( { + value: index + 1, + label: undefined, + } ) ); const sideLabel = ALL_SIDES.includes( side ) && showSideInLabel ? LABELS[ side ] : ''; @@ -247,6 +249,7 @@ export default function SpacingInputControl( { } } /> .components-base-control__field { - /* Fixes RangeControl contents when the outer wrapper is flex */ - flex: 1; - } } .components-range-control__mark { + transform: translateX(-50%); height: $grid-unit-05; - width: 3px; - background-color: #fff; + width: math.div($grid-unit-05, 2); + background-color: $white; z-index: 1; + top: -#{$grid-unit-05}; } .components-range-control__marks { margin-top: 17px; - - :first-child { - display: none; - } } .components-range-control__thumb-wrapper { diff --git a/packages/block-editor/src/components/text-alignment-control/index.js b/packages/block-editor/src/components/text-alignment-control/index.js index 88a6fe274ea09b..6eeaad784db0f0 100644 --- a/packages/block-editor/src/components/text-alignment-control/index.js +++ b/packages/block-editor/src/components/text-alignment-control/index.js @@ -14,11 +14,10 @@ import { alignJustify, } from '@wordpress/icons'; import { useMemo } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SegmentedTextControl from '../segmented-text-control'; +import { + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon, +} from '@wordpress/components'; const TEXT_ALIGNMENT_OPTIONS = [ { @@ -75,9 +74,11 @@ export default function TextAlignmentControl( { } return ( - { onChange( newValue === value ? undefined : newValue ); } } - /> + > + { validOptions.map( ( option ) => { + return ( + + ); + } ) } + ); } diff --git a/packages/block-editor/src/components/text-decoration-control/index.js b/packages/block-editor/src/components/text-decoration-control/index.js index d06632afdbb3ca..720f3d2d9558eb 100644 --- a/packages/block-editor/src/components/text-decoration-control/index.js +++ b/packages/block-editor/src/components/text-decoration-control/index.js @@ -8,11 +8,10 @@ import clsx from 'clsx'; */ import { reset, formatStrikethrough, formatUnderline } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import SegmentedTextControl from '../segmented-text-control'; +import { + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon, +} from '@wordpress/components'; const TEXT_DECORATIONS = [ { @@ -48,9 +47,11 @@ export default function TextDecorationControl( { className, } ) { return ( - { onChange( newValue === value ? undefined : newValue ); } } - /> + > + { TEXT_DECORATIONS.map( ( option ) => { + return ( + + ); + } ) } + ); } diff --git a/packages/block-editor/src/components/text-transform-control/index.js b/packages/block-editor/src/components/text-transform-control/index.js index f448a55ed946c3..a3183e630c328a 100644 --- a/packages/block-editor/src/components/text-transform-control/index.js +++ b/packages/block-editor/src/components/text-transform-control/index.js @@ -13,11 +13,10 @@ import { formatLowercase, formatUppercase, } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import SegmentedTextControl from '../segmented-text-control'; +import { + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon, +} from '@wordpress/components'; const TEXT_TRANSFORMS = [ { @@ -54,9 +53,11 @@ const TEXT_TRANSFORMS = [ */ export default function TextTransformControl( { className, value, onChange } ) { return ( - { onChange( newValue === value ? undefined : newValue ); } } - /> + > + { TEXT_TRANSFORMS.map( ( option ) => { + return ( + + ); + } ) } + ); } diff --git a/packages/block-editor/src/components/url-popover/image-url-input-ui.js b/packages/block-editor/src/components/url-popover/image-url-input-ui.js index 8209ff1939f091..c19021c52356f5 100644 --- a/packages/block-editor/src/components/url-popover/image-url-input-ui.js +++ b/packages/block-editor/src/components/url-popover/image-url-input-ui.js @@ -227,12 +227,16 @@ const ImageURLInputUI = ( { checked={ linkTarget === '_blank' } /> { onChange( newValue === value ? undefined : newValue ); } } - /> + > + { WRITING_MODES.map( ( option ) => { + return ( + + ); + } ) } + ); } diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index cd0b017831b795..3755aecbcb9d0b 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -16,16 +16,14 @@ import { useHasBackgroundPanel, hasBackgroundImageValue, } from '../components/global-styles/background-panel'; -import { - globalStylesDataKey, - globalStylesLinksDataKey, -} from '../store/private-keys'; +import { globalStylesDataKey } from '../store/private-keys'; export const BACKGROUND_SUPPORT_KEY = 'background'; -// Initial control values where no block style is set. -const BACKGROUND_DEFAULT_VALUES = { +// Initial control values. +export const BACKGROUND_BLOCK_DEFAULT_VALUES = { backgroundSize: 'cover', + backgroundPosition: '50% 50%', // used only when backgroundSize is 'contain'. }; /** @@ -55,31 +53,28 @@ export function hasBackgroundSupport( blockName, feature = 'any' ) { } export function setBackgroundStyleDefaults( backgroundStyle ) { - if ( ! backgroundStyle ) { + if ( ! backgroundStyle || ! backgroundStyle?.backgroundImage?.url ) { return; } - const backgroundImage = backgroundStyle?.backgroundImage; let backgroundStylesWithDefaults; // Set block background defaults. - if ( !! backgroundImage?.url ) { - if ( ! backgroundStyle?.backgroundSize ) { - backgroundStylesWithDefaults = { - backgroundSize: 'cover', - }; - } - - if ( - 'contain' === backgroundStyle?.backgroundSize && - ! backgroundStyle?.backgroundPosition - ) { - backgroundStylesWithDefaults = { - backgroundPosition: 'center', - }; - } + if ( ! backgroundStyle?.backgroundSize ) { + backgroundStylesWithDefaults = { + backgroundSize: BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundSize, + }; } + if ( + 'contain' === backgroundStyle?.backgroundSize && + ! backgroundStyle?.backgroundPosition + ) { + backgroundStylesWithDefaults = { + backgroundPosition: + BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundPosition, + }; + } return backgroundStylesWithDefaults; } @@ -138,15 +133,15 @@ export function BackgroundImagePanel( { setAttributes, settings, } ) { - const { style, inheritedValue, _links } = useSelect( + const { style, inheritedValue } = useSelect( ( select ) => { const { getBlockAttributes, getSettings } = select( blockEditorStore ); const _settings = getSettings(); return { style: getBlockAttributes( clientId )?.style, - _links: _settings[ globalStylesLinksDataKey ], /* + * To ensure we pass down the right inherited values: * @TODO 1. Pass inherited value down to all block style controls, * See: packages/block-editor/src/hooks/style.js * @TODO 2. Add support for block style variations, @@ -187,11 +182,10 @@ export function BackgroundImagePanel( { inheritedValue={ inheritedValue } as={ BackgroundInspectorControl } panelId={ clientId } - defaultValues={ BACKGROUND_DEFAULT_VALUES } + defaultValues={ BACKGROUND_BLOCK_DEFAULT_VALUES } settings={ updatedSettings } onChange={ onChange } value={ style } - themeFileURIs={ _links?.[ 'wp:theme-file' ] } /> ); } diff --git a/packages/block-editor/src/hooks/block-bindings.scss b/packages/block-editor/src/hooks/block-bindings.scss index 73e7c490160d3e..603b2115623b8f 100644 --- a/packages/block-editor/src/hooks/block-bindings.scss +++ b/packages/block-editor/src/hooks/block-bindings.scss @@ -1,5 +1,5 @@ div.block-editor-bindings__panel { - grid-template-columns: auto; + grid-template-columns: repeat(auto-fit, minmax(100%, 1fr)); button:hover .block-editor-bindings__item-explanation { color: inherit; } diff --git a/packages/block-editor/src/hooks/test/background.js b/packages/block-editor/src/hooks/test/background.js new file mode 100644 index 00000000000000..030e88ae67fbd8 --- /dev/null +++ b/packages/block-editor/src/hooks/test/background.js @@ -0,0 +1,60 @@ +/** + * Internal dependencies + */ +import { + setBackgroundStyleDefaults, + BACKGROUND_BLOCK_DEFAULT_VALUES, +} from '../background'; + +describe( 'background', () => { + describe( 'setBackgroundStyleDefaults', () => { + const backgroundStyles = { + backgroundImage: { id: 123, url: 'image.png' }, + }; + const backgroundStylesContain = { + backgroundImage: { id: 123, url: 'image.png' }, + backgroundSize: 'contain', + }; + const backgroundStylesNoURL = { backgroundImage: { id: 123 } }; + it.each( [ + [ + 'return background size default', + backgroundStyles, + { + backgroundSize: + BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundSize, + }, + ], + [ 'return early if no styles are passed', undefined, undefined ], + [ + 'return early if images has no id', + backgroundStylesNoURL, + undefined, + ], + [ + 'return early if images has no URL', + backgroundStylesNoURL, + undefined, + ], + [ + 'return background position default', + backgroundStylesContain, + { + backgroundPosition: + BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundPosition, + }, + ], + [ + 'not apply background position value if one already exists in styles', + { + ...backgroundStylesContain, + backgroundPosition: 'center', + }, + undefined, + ], + ] )( 'should %s', ( message, styles, expected ) => { + const result = setBackgroundStyleDefaults( styles ); + expect( result ).toEqual( expected ); + } ); + } ); +} ); diff --git a/packages/block-editor/src/layouts/constrained.js b/packages/block-editor/src/layouts/constrained.js index 2e671e8e53975a..b48e331792bd8a 100644 --- a/packages/block-editor/src/layouts/constrained.js +++ b/packages/block-editor/src/layouts/constrained.js @@ -125,6 +125,7 @@ export default { ) } { allowJustification && ( ) : ( @@ -449,6 +451,7 @@ function GridLayoutTypeControl( { layout, onChange } ) { return ( + clientIds.reduce( ( styles, clientId ) => { + styles[ clientId ] = state.blocks.attributes.get( clientId )?.style; + return styles; + }, {} ), + ( state, clientIds ) => [ + ...clientIds.map( + ( clientId ) => state.blocks.attributes.get( clientId )?.style + ), + ] +); + +/** + * Returns whether zoom out mode is enabled. + * + * @param {Object} state Editor state. + * + * @return {boolean} Is zoom out mode enabled. + */ +export function isZoomOutMode( state ) { + return state.editorMode === 'zoom-out'; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index cd4569c45e5801..d9352670776371 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1624,6 +1624,8 @@ export function insertionPoint( state = null, action ) { } case 'HIDE_INSERTION_POINT': + case 'CLEAR_SELECTED_BLOCK': + case 'SELECT_BLOCK': return null; } @@ -2085,6 +2087,10 @@ export function hoveredBlockClientId( state = false, action ) { return state; } +export function inserterSearchInputRef( state = { current: null } ) { + return state; +} + const combinedReducers = combineReducers( { blocks, isDragging, @@ -2118,6 +2124,7 @@ const combinedReducers = combineReducers( { openedBlockSettingsMenu, registeredInserterMediaCategories, hoveredBlockClientId, + inserterSearchInputRef, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 185da1ffb98046..45432b750bb9eb 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -9,6 +9,7 @@ import { getEnabledBlockParents, getExpandedBlock, isDragging, + getBlockStyles, } from '../private-selectors'; import { getBlockEditingMode } from '../selectors'; @@ -509,4 +510,92 @@ describe( 'private selectors', () => { ); } ); } ); + + describe( 'getBlockStyles', () => { + it( 'should return an empty object when no client IDs are provided', () => { + const state = { + blocks: { + attributes: new Map(), + }, + }; + const result = getBlockStyles( state, [] ); + expect( result ).toEqual( {} ); + } ); + + it( 'should return styles for a single block', () => { + const state = { + blocks: { + attributes: new Map( [ + [ 'block-1', { style: { color: 'red' } } ], + ] ), + }, + }; + const result = getBlockStyles( state, [ 'block-1' ] ); + expect( result ).toEqual( { + 'block-1': { color: 'red' }, + } ); + } ); + + it( 'should return styles for multiple blocks', () => { + const state = { + blocks: { + attributes: new Map( [ + [ 'block-1', { style: { color: 'red' } } ], + [ 'block-2', { style: { fontSize: '16px' } } ], + [ 'block-3', { style: { margin: '10px' } } ], + ] ), + }, + }; + const result = getBlockStyles( state, [ + 'block-1', + 'block-2', + 'block-3', + ] ); + expect( result ).toEqual( { + 'block-1': { color: 'red' }, + 'block-2': { fontSize: '16px' }, + 'block-3': { margin: '10px' }, + } ); + } ); + + it( 'should return undefined for blocks without styles', () => { + const state = { + blocks: { + attributes: new Map( [ + [ 'block-1', { style: { color: 'red' } } ], + [ 'block-2', {} ], + [ 'block-3', { style: { margin: '10px' } } ], + ] ), + }, + }; + const result = getBlockStyles( state, [ + 'block-1', + 'block-2', + 'block-3', + ] ); + expect( result ).toEqual( { + 'block-1': { color: 'red' }, + 'block-2': undefined, + 'block-3': { margin: '10px' }, + } ); + } ); + + it( 'should return undefined for non-existent blocks', () => { + const state = { + blocks: { + attributes: new Map( [ + [ 'block-1', { style: { color: 'red' } } ], + ] ), + }, + }; + const result = getBlockStyles( state, [ + 'block-1', + 'non-existent-block', + ] ); + expect( result ).toEqual( { + 'block-1': { color: 'red' }, + 'non-existent-block': undefined, + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss index 47b87bb50918df..feaabbbda94426 100644 --- a/packages/block-editor/src/style.scss +++ b/packages/block-editor/src/style.scss @@ -40,7 +40,6 @@ @import "./components/multi-selection-inspector/style.scss"; @import "./components/responsive-block-control/style.scss"; @import "./components/rich-text/style.scss"; -@import "./components/segmented-text-control/style.scss"; @import "./components/skip-to-selected-block/style.scss"; @import "./components/tabbed-sidebar/style.scss"; @import "./components/tool-selector/style.scss"; diff --git a/packages/block-editor/src/utils/test/transform-styles.js b/packages/block-editor/src/utils/test/transform-styles.js index 8245ce62831078..b0c6ca48deb355 100644 --- a/packages/block-editor/src/utils/test/transform-styles.js +++ b/packages/block-editor/src/utils/test/transform-styles.js @@ -125,6 +125,21 @@ describe( 'transformStyles', () => { expect( output ).toMatchSnapshot(); } ); + it( `should not try to replace 'body' in the middle of a classname`, () => { + const prefix = '.my-namespace'; + const input = `.has-body-text { color: red; }`; + const output = transformStyles( + [ + { + css: input, + }, + ], + prefix + ); + + expect( output ).toEqual( [ `${ prefix } ${ input }` ] ); + } ); + it( 'should ignore keyframes', () => { const input = ` @keyframes edit-post__fade-in-animation { @@ -210,6 +225,40 @@ describe( 'transformStyles', () => { expect( output ).toMatchSnapshot(); } ); + + it( 'should not try to wrap items within `:where` selectors', () => { + const input = `:where(.wp-element-button:active, .wp-block-button__link:active) { color: blue; }`; + const prefix = '.my-namespace'; + const expected = [ `${ prefix } ${ input }` ]; + + const output = transformStyles( + [ + { + css: input, + }, + ], + prefix + ); + + expect( output ).toEqual( expected ); + } ); + + it( 'should not try to prefix pseudo elements on `:where` selectors', () => { + const input = `:where(.wp-element-button, .wp-block-button__link)::before { color: blue; }`; + const prefix = '.my-namespace'; + const expected = [ `${ prefix } ${ input }` ]; + + const output = transformStyles( + [ + { + css: input, + }, + ], + prefix + ); + + expect( output ).toEqual( expected ); + } ); } ); it( 'should not break with data urls', () => { diff --git a/packages/block-library/src/archives/edit.js b/packages/block-library/src/archives/edit.js index 4eed206314e427..ee9bf60fb77ec0 100644 --- a/packages/block-library/src/archives/edit.js +++ b/packages/block-library/src/archives/edit.js @@ -51,6 +51,7 @@ export default function ArchivesEdit( { attributes, setAttributes } ) { } />
{ /* - Disable the audio tag if the block is not selected - so the user clicking on it won't play the - file or change the position slider when the controls are enabled. + Disable the audio tag if the block is not selected + so the user clicking on it won't play the + file or change the position slider when the controls are enabled. */ }
); } diff --git a/packages/block-library/src/file/inspector.js b/packages/block-library/src/file/inspector.js index 76ed28d124600e..c29f84f60ebace 100644 --- a/packages/block-library/src/file/inspector.js +++ b/packages/block-library/src/file/inspector.js @@ -73,6 +73,7 @@ export default function FileBlockInspector( { ) } { { submissionMethod !== 'email' && ( { ) } /> ) } { Platform.isWeb && ! imageSizeOptions && hasImageIds && ( - + { __( 'Resolution' ) } @@ -696,7 +696,3 @@ function GalleryEdit( props ) { ); } -export default compose( [ - withNotices, - withViewportMatch( { isNarrow: '< small' } ), -] )( GalleryEdit ); diff --git a/packages/block-library/src/gallery/gallery.native.js b/packages/block-library/src/gallery/gallery.native.js index 7fc280809db843..a5fa2db4fcf029 100644 --- a/packages/block-library/src/gallery/gallery.native.js +++ b/packages/block-library/src/gallery/gallery.native.js @@ -22,6 +22,7 @@ import { useState, useEffect } from '@wordpress/element'; import { mediaUploadSync } from '@wordpress/react-native-bridge'; import { WIDE_ALIGNMENTS } from '@wordpress/components'; import { useResizeObserver } from '@wordpress/compose'; +import { withViewportMatch } from '@wordpress/viewport'; const TILE_SPACING = 8; @@ -120,4 +121,4 @@ export const Gallery = ( props ) => { ); }; -export default Gallery; +export default withViewportMatch( { isNarrow: '< small' } )( Gallery ); diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 7dd03a7fb5837c..0623d576e75aec 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -690,6 +690,8 @@ export default function Image( { ) } renderContent={ () => ( @@ -303,21 +334,32 @@ export default function LatestPostsEdit( { attributes, setAttributes } ) { } ) } /> - - - { __( 'Image alignment' ) } - - - setAttributes( { - featuredImageAlign: value, - } ) + + setAttributes( { + featuredImageAlign: + value !== 'none' ? value : undefined, + } ) + } + > + { imageAlignmentOptions.map( + ( { value, icon, label } ) => { + return ( + + ); } - controls={ [ 'left', 'center', 'right' ] } - isCollapsed={ false } - /> - + ) } +
li" + "root": ".wp-block-list > li", + "border": ".wp-block-list:not(.wp-block-list .wp-block-list) > li" } } diff --git a/packages/block-library/src/list/block.json b/packages/block-library/src/list/block.json index 8b100071c15ea9..ea07a0eb542df3 100644 --- a/packages/block-library/src/list/block.json +++ b/packages/block-library/src/list/block.json @@ -39,6 +39,12 @@ "supports": { "anchor": true, "html": false, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, "typography": { "fontSize": true, "lineHeight": true, @@ -75,6 +81,9 @@ "clientNavigation": true } }, + "selectors": { + "border": ".wp-block-list:not(.wp-block-list .wp-block-list)" + }, "editorStyle": "wp-block-list-editor", "style": "wp-block-list" } diff --git a/packages/block-library/src/media-text/edit.js b/packages/block-library/src/media-text/edit.js index 5a3b4fab04ab63..2c020506dc889c 100644 --- a/packages/block-library/src/media-text/edit.js +++ b/packages/block-library/src/media-text/edit.js @@ -360,7 +360,6 @@ function MediaTextEdit( { > ) } -

{ __( 'Overlay Menu' ) }

setAttributes( { icon: value } ) } diff --git a/packages/block-library/src/navigation/editor.scss b/packages/block-library/src/navigation/editor.scss index b279dc08cfe6e7..24480bb1592516 100644 --- a/packages/block-library/src/navigation/editor.scss +++ b/packages/block-library/src/navigation/editor.scss @@ -628,3 +628,8 @@ body.editor-styles-wrapper .wp-block-navigation__responsive-container.is-menu-op .wp-block-navigation__menu-inspector-controls__empty-message { margin-left: 24px; } + +.wp-block-navigation__overlay-menu-icon-toggle-group { + // Counteract the margin added by the block inspector. + margin-bottom: $grid-unit-20; +} diff --git a/packages/block-library/src/post-author-name/block.json b/packages/block-library/src/post-author-name/block.json index 31874ddbf9bc5d..68d2c49bd91056 100644 --- a/packages/block-library/src/post-author-name/block.json +++ b/packages/block-library/src/post-author-name/block.json @@ -53,6 +53,19 @@ }, "interactivity": { "clientNavigation": true + }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": true, + "color": true, + "width": true, + "style": true + } } - } + }, + "style": "wp-block-post-author-name" } diff --git a/packages/block-library/src/post-author-name/style.scss b/packages/block-library/src/post-author-name/style.scss new file mode 100644 index 00000000000000..0f57b30490fa68 --- /dev/null +++ b/packages/block-library/src/post-author-name/style.scss @@ -0,0 +1,4 @@ +.wp-block-post-author-name { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; +} diff --git a/packages/block-library/src/post-author/block.json b/packages/block-library/src/post-author/block.json index 6f814810744c6d..dde9320841820d 100644 --- a/packages/block-library/src/post-author/block.json +++ b/packages/block-library/src/post-author/block.json @@ -66,5 +66,6 @@ "clientNavigation": true } }, + "editorStyle": "wp-block-post-author-editor", "style": "wp-block-post-author" } diff --git a/packages/block-library/src/post-author/edit.js b/packages/block-library/src/post-author/edit.js index dfe0a001d2ee59..6186b0d052e8aa 100644 --- a/packages/block-library/src/post-author/edit.js +++ b/packages/block-library/src/post-author/edit.js @@ -18,6 +18,7 @@ import { PanelBody, SelectControl, ToggleControl, + __experimentalVStack as VStack, } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -100,72 +101,82 @@ function PostAuthorEdit( { <> - { showAuthorControl && - ( ( showCombobox && ( - - ) ) || ( + + { showAuthorControl && + ( ( showCombobox && ( + + ) ) || ( + + ) ) } + + setAttributes( { showAvatar: ! showAvatar } ) + } + /> + { showAvatar && ( { + setAttributes( { + avatarSize: Number( size ), + } ); + } } /> - ) ) } - - setAttributes( { showAvatar: ! showAvatar } ) - } - /> - { showAvatar && ( - { - setAttributes( { - avatarSize: Number( size ), - } ); - } } + label={ __( 'Show bio' ) } + checked={ showBio } + onChange={ () => + setAttributes( { showBio: ! showBio } ) + } /> - ) } - - setAttributes( { showBio: ! showBio } ) - } - /> - setAttributes( { isLink: ! isLink } ) } - /> - { isLink && ( - setAttributes( { - linkTarget: value ? '_blank' : '_self', - } ) + label={ __( 'Link author name to author page' ) } + checked={ isLink } + onChange={ () => + setAttributes( { isLink: ! isLink } ) } - checked={ linkTarget === '_blank' } /> - ) } + { isLink && ( + + setAttributes( { + linkTarget: value ? '_blank' : '_self', + } ) + } + checked={ linkTarget === '_blank' } + /> + ) } + diff --git a/packages/block-library/src/post-author/editor.scss b/packages/block-library/src/post-author/editor.scss new file mode 100644 index 00000000000000..f6464893138cff --- /dev/null +++ b/packages/block-library/src/post-author/editor.scss @@ -0,0 +1,7 @@ +.wp-block-post-author__inspector-settings { + // Counteract the margin added by the block inspector. + .components-base-control, + .components-base-control:last-child { + margin-bottom: 0; + } +} diff --git a/packages/block-library/src/post-comment/edit.js b/packages/block-library/src/post-comment/edit.js index 54ad72be30d7c0..f6abb5ff60c68e 100644 --- a/packages/block-library/src/post-comment/edit.js +++ b/packages/block-library/src/post-comment/edit.js @@ -34,6 +34,8 @@ export default function Edit( { attributes: { commentId }, setAttributes } ) { ) } > diff --git a/packages/block-library/src/post-excerpt/edit.js b/packages/block-library/src/post-excerpt/edit.js index 45d0a79a411d61..05aaf543b59196 100644 --- a/packages/block-library/src/post-excerpt/edit.js +++ b/packages/block-library/src/post-excerpt/edit.js @@ -231,6 +231,7 @@ export default function PostExcerptEditor( { } /> ) } +

"This will make running your own blog a viable alternative again."

Adrian Zumbrunnen
diff --git a/packages/block-library/src/quote/transforms.js b/packages/block-library/src/quote/transforms.js index f9b3970433fad6..c960759691bf16 100644 --- a/packages/block-library/src/quote/transforms.js +++ b/packages/block-library/src/quote/transforms.js @@ -9,10 +9,18 @@ const transforms = { { type: 'block', blocks: [ 'core/pullquote' ], - transform: ( { value, citation, anchor, fontSize, style } ) => { + transform: ( { + value, + align, + citation, + anchor, + fontSize, + style, + } ) => { return createBlock( 'core/quote', { + align, citation, anchor, fontSize, @@ -95,7 +103,7 @@ const transforms = { ); }, transform: ( - { citation, anchor, fontSize, style }, + { align, citation, anchor, fontSize, style }, innerBlocks ) => { const value = innerBlocks @@ -103,6 +111,7 @@ const transforms = { .join( '
' ); return createBlock( 'core/pullquote', { value, + align, citation, anchor, fontSize, diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index cfe7b29caf5de0..e2f3bb3999e42c 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -28,7 +28,7 @@ import { ToolbarButton, ResizableBox, PanelBody, - BaseControl, + __experimentalVStack as VStack, __experimentalUseCustomUnits as useCustomUnits, __experimentalUnitControl as UnitControl, } from '@wordpress/components'; @@ -408,12 +408,14 @@ export default function SearchEdit( { - 100 ? 100 : newWidth; - setAttributes( { width: parseInt( filteredWidth, 10 ), } ); @@ -445,9 +446,8 @@ export default function SearchEdit( { value={ `${ width }${ widthUnit }` } units={ units } /> - { [ 25, 50, 75, 100 ].map( ( widthValue ) => { @@ -473,7 +473,7 @@ export default function SearchEdit( { ); } ) } - + diff --git a/packages/block-library/src/search/editor.scss b/packages/block-library/src/search/editor.scss index 35ccfc5e633fc2..ecc244d3341e1e 100644 --- a/packages/block-library/src/search/editor.scss +++ b/packages/block-library/src/search/editor.scss @@ -15,8 +15,11 @@ justify-content: center; text-align: center; } +} - &__components-button-group { - margin-top: 10px; +.wp-block-search__inspector-controls { + .components-base-control { + // Counteract the margin added by the block inspector. + margin-bottom: 0; } } diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js index 8f372a73ea6c9d..39e3803982f48b 100644 --- a/packages/block-library/src/social-link/edit.js +++ b/packages/block-library/src/social-link/edit.js @@ -139,6 +139,8 @@ const SocialLinkEdit = ( { { - if ( ! isSelected ) { + if ( ! isSingleSelected ) { setSelectedCell(); } - }, [ isSelected ] ); + }, [ isSingleSelected ] ); useEffect( () => { if ( hasTableCreated ) { @@ -565,9 +565,10 @@ function TableEdit( { ) } diff --git a/packages/block-library/src/tag-cloud/block.json b/packages/block-library/src/tag-cloud/block.json index 0c2095bff2a152..044bc0c5333768 100644 --- a/packages/block-library/src/tag-cloud/block.json +++ b/packages/block-library/src/tag-cloud/block.json @@ -51,6 +51,18 @@ }, "interactivity": { "clientNavigation": true + }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": true, + "color": true, + "width": true, + "style": true + } } }, "editorStyle": "wp-block-tag-cloud-editor" diff --git a/packages/block-library/src/tag-cloud/edit.js b/packages/block-library/src/tag-cloud/edit.js index 9a2b531b30f8ab..eeb568e7a89ef1 100644 --- a/packages/block-library/src/tag-cloud/edit.js +++ b/packages/block-library/src/tag-cloud/edit.js @@ -11,8 +11,8 @@ import { __experimentalUnitControl as UnitControl, __experimentalUseCustomUnits as useCustomUnits, __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, + __experimentalVStack as VStack, Disabled, - BaseControl, } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -107,20 +107,32 @@ function TagCloudEdit( { attributes, setAttributes } ) { setAttributes( updateObj ); }; + // Remove border styles from the server-side attributes to prevent duplicate border. + const serverSideAttributes = { + ...attributes, + style: { + ...attributes?.style, + border: undefined, + }, + }; + const inspectorControls = ( - - setAttributes( { taxonomy: selectedTaxonomy } ) - } - /> - + + + setAttributes( { taxonomy: selectedTaxonomy } ) + } + /> - - - setAttributes( { numberOfTags: value } ) - } - min={ MIN_TAGS } - max={ MAX_TAGS } - required - /> - - setAttributes( { showTagCounts: ! showTagCounts } ) - } - /> + + setAttributes( { numberOfTags: value } ) + } + min={ MIN_TAGS } + max={ MAX_TAGS } + required + /> + + setAttributes( { showTagCounts: ! showTagCounts } ) + } + /> + ); @@ -188,7 +200,7 @@ function TagCloudEdit( { attributes, setAttributes } ) {
diff --git a/packages/block-library/src/tag-cloud/editor.scss b/packages/block-library/src/tag-cloud/editor.scss index de2a95a386fa85..e85129e22f1aca 100644 --- a/packages/block-library/src/tag-cloud/editor.scss +++ b/packages/block-library/src/tag-cloud/editor.scss @@ -1,4 +1,4 @@ -// The following styles are to prevent duplicate spacing for the tag cloud +// The following styles are to prevent duplicate spacing and border for the tag cloud // block in the editor given it uses server side rendering. The specificity // must be higher than `0-1-0` to override global styles. Targeting the // inner use of the .wp-block-tag-cloud class should minimize impact on @@ -6,4 +6,14 @@ .wp-block-tag-cloud .wp-block-tag-cloud { margin: 0; padding: 0; + border: none; + border-radius: inherit; +} + +.wp-block-tag-cloud__inspector-settings { + .components-base-control, + .components-base-control:last-child { + // Cancel out extra margins added by block inspector + margin-bottom: 0; + } } diff --git a/packages/block-library/src/template-part/edit/advanced-controls.js b/packages/block-library/src/template-part/edit/advanced-controls.js index 44562c472dd4f6..bf834065d1a54b 100644 --- a/packages/block-library/src/template-part/edit/advanced-controls.js +++ b/packages/block-library/src/template-part/edit/advanced-controls.js @@ -73,6 +73,8 @@ export function TemplatePartAdvancedControls( { { isEntityAvailable && ( <> { ) } /> - +
{ __( 'Poster image' ) } @@ -265,7 +265,7 @@ function VideoEdit( { { __( 'Remove' ) } ) } - +
diff --git a/packages/block-library/src/video/tracks-editor.js b/packages/block-library/src/video/tracks-editor.js index 366d521f5aa625..521d54e9231d1b 100644 --- a/packages/block-library/src/video/tracks-editor.js +++ b/packages/block-library/src/video/tracks-editor.js @@ -99,6 +99,8 @@ function SingleTrackEditor( { track, onChange, onClose, onRemove } ) { onChange( { @@ -128,6 +132,8 @@ function SingleTrackEditor( { track, onChange, onClose, onRemove } ) { ``` ```jsx // ✅ Do: - - - + + + ``` @@ -185,25 +181,25 @@ One way to enable reusability and composition is to extract a component's underl ```tsx // in `hook.ts` -function useExampleComponent( props: PolymorphicComponentProps< ExampleProps, 'div' > ) { +function useExampleComponent( + props: PolymorphicComponentProps< ExampleProps, 'div' > +) { // Merge received props with the context system. - const { isVisible, className, ...otherProps } = useContextSystem( props, 'Example' ); + const { isVisible, className, ...otherProps } = useContextSystem( + props, + 'Example' + ); // Any other reusable rendering logic (e.g. computing className, state, event listeners...) const cx = useCx(); const classes = useMemo( - () => - cx( - styles.example, - isVisible && styles.visible, - className - ), + () => cx( styles.example, isVisible && styles.visible, className ), [ className, isVisible ] ); return { ...otherProps, - className: classes + className: classes, }; } @@ -220,8 +216,8 @@ function Example( A couple of good examples of how hooks are used for composition are: -- the `Card` component, which builds on top of the `Surface` component by [calling the `useSurface` hook inside its own hook](/packages/components/src/card/card/hook.ts); -- the `HStack` component, which builds on top of the `Flex` component and [calls the `useFlex` hook inside its own hook](/packages/components/src/h-stack/hook.tsx). +- the `Card` component, which builds on top of the `Surface` component by [calling the `useSurface` hook inside its own hook](/packages/components/src/card/card/hook.ts); +- the `HStack` component, which builds on top of the `Flex` component and [calls the `useFlex` hook inside its own hook](/packages/components/src/h-stack/hook.tsx). +## Naming Conventions + +It is recommended that compound components use dot notation to separate the namespace from the individual component names. The top-level compound component should be called the namespace (no dot notation). + +Dedicated React context should also use dot notation, while hooks should not. + +When exporting compound components and preparing them to be consumed, it is important that: + +- the JSDocs appear correctly in IntelliSense; +- the top-level component's JSDoc appears in the Storybook docs page; +- the top-level and subcomponent's prop types appear correctly in the Storybook props table. + +To meet the above requirements, we recommend: + +- using `Object.assign()` to add subcomponents as properties of the top-level component; +- using named functions for all components; +- setting explicitly the `displayName` on all subcomponents; +- adding the top-level JSDoc to the result of the `Object.assign` call; +- adding inline subcomponent JSDocs inside the `Object.assign` call. + +The following example implements all of the above recommendations. + +```tsx +//======================= +// Component.tsx +//======================= +import { forwardRef, createContext } from '@wordpress/element'; + +function UnforwardedTopLevelComponent( props, ref ) { + /* ... */ +} +const TopLevelComponent = forwardRef( UnforwardedTopLevelComponent ); + +function UnforwardedSubComponent( props, ref ) { + /* ... */ +} +const SubComponent = forwardRef( UnforwardedSubComponent ); +SubComponent.displayName = 'Component.SubComponent'; + +const Context = createContext(); + +/** The top-level component's JSDoc. */ +export const Component = Object.assign( TopLevelComponent, { + /** The subcomponent's JSDoc. */ + SubComponent, + /** The context's JSDoc. */ + Context, +} ); + +/** The hook's JSDoc. */ +export function useComponent() { + /* ... */ +} + +//======================= +// App.tsx +//======================= +import { Component, useComponent } from '@wordpress/components'; +import { useContext } from '@wordpress/element'; + +function CompoundComponentExample() { + return ( + + + + ); +} + +function ContextProviderExample() { + return ( + + { /* React tree */ } + + ); +} + +function ContextConsumerExample() { + const componentContext = useContext( Component.Context ); + + // etc +} + +function HookExample() { + const hookReturnValue = useComponent(); + + // etc. +} +``` + ## TypeScript We strongly encourage using TypeScript for all new components. @@ -278,8 +363,10 @@ function UnconnectedMyComponent( // parameter (`div` in this example) // - the special `as` prop (which marks the component as polymorphic), // unless the third parameter is `false` - props: WordPressComponentProps< ComponentOwnProps, 'div', true > -) { /* ... */ } + props: WordPressComponentProps< ComponentOwnProps, 'div', true > +) { + /* ... */ +} ``` ### Considerations for the docgen @@ -287,10 +374,15 @@ function UnconnectedMyComponent( Make sure you have a **named** export for the component, not just the default export ([example](https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/divider/component.tsx)). This ensures that the docgen can properly extract the types data. The naming should be so that the connected/forwarded component has the plain component name (`MyComponent`), and the raw component is prefixed (`UnconnectedMyComponent` or `UnforwardedMyComponent`). This makes the component's `displayName` look nicer in React devtools and in the autogenerated Storybook code snippets. ```js -function UnconnectedMyComponent() { /* ... */ } +function UnconnectedMyComponent() { + /* ... */ +} // 👇 Without this named export, the docgen will not work! -export const MyComponent = contextConnect( UnconnectedMyComponent, 'MyComponent' ); +export const MyComponent = contextConnect( + UnconnectedMyComponent, + 'MyComponent' +); export default MyComponent; ``` @@ -314,16 +406,15 @@ Changing the styles of a non-experimental component must be done with care. To p import deprecated from '@wordpress/deprecated'; import { Wrapper } from './styles.ts'; -function MyComponent({ __nextHasNoOuterMargins = false }) { +function MyComponent( { __nextHasNoOuterMargins = false } ) { if ( ! __nextHasNoOuterMargins ) { deprecated( 'Outer margin styles for wp.components.MyComponent', { since: '6.0', version: '6.2', // Set a reasonable grace period depending on impact - hint: - 'Set the `__nextHasNoOuterMargins` prop to true to start opting into the new styles, which will become the default in a future version.', + hint: 'Set the `__nextHasNoOuterMargins` prop to true to start opting into the new styles, which will become the default in a future version.', } ); } - return + return ; } ``` @@ -331,7 +422,7 @@ Styles should be structured so the deprecated styles are cleanly encapsulated, a ```js // styles.ts -const deprecatedMargins = ({ __nextHasNoOuterMargins }) => { +const deprecatedMargins = ( { __nextHasNoOuterMargins } ) => { if ( ! __nextHasNoOuterMargins ) { return css` margin: 8px; @@ -342,7 +433,7 @@ const deprecatedMargins = ({ __nextHasNoOuterMargins }) => { export const Wrapper = styled.div` margin: 0; - ${deprecatedMargins} + ${ deprecatedMargins } `; ``` @@ -358,14 +449,14 @@ Not all style changes justify a formal deprecation process. The main thing to lo ##### DOES need formal deprecation -- Removing an outer margin. -- Substantial changes to width/height, such as adding or removing a size restriction. +- Removing an outer margin. +- Substantial changes to width/height, such as adding or removing a size restriction. ##### DOES NOT need formal deprecation -- Breakage only occurs in non-standard usage, such as when the consumer is overriding component internals. -- Minor layout shifts of a few pixels. -- Internal layout changes of a higher-level component. +- Breakage only occurs in non-standard usage, such as when the consumer is overriding component internals. +- Minor layout shifts of a few pixels. +- Internal layout changes of a higher-level component. ## Context system @@ -373,9 +464,9 @@ The `@wordpress/components` context system is based on [React's `Context` API](h Components can use this system via a couple of functions: -- they can provide values using a shared `ContextSystemProvider` component -- they can connect to the Context via `contextConnect` -- they can read the "computed" values from the context via `useContextSystem` +- they can provide values using a shared `ContextSystemProvider` component +- they can connect to the Context via `contextConnect` +- they can read the "computed" values from the context via `useContextSystem` An example of how this is used can be found in the [`Card` component family](/packages/components/src/card). For example, this is how the `Card` component injects the `size` and `isBorderless` props down to its `CardBody` subcomponent — which makes it use the correct spacing and border settings "auto-magically". @@ -400,11 +491,7 @@ export function useCard( props ) { import { contextConnect, ContextSystemProvider } from '../../context'; function Card( props, forwardedRef ) { - const { - size, - isBorderless, - ...otherComputedHookProps - } = useCard( props ); + const { size, isBorderless, ...otherComputedHookProps } = useCard( props ); // [...] @@ -441,7 +528,10 @@ export function useCardBody( props ) { // If a `CardBody` component is rendered as a child of a `Card` component, the value of // the `size` prop will be the one set by the parent `Card` component via the Context // System (unless the prop gets explicitely set on the `CardBody` component). - const { size = 'medium', ...otherDerivedProps } = useContextSystem( props, 'CardBody' ); + const { size = 'medium', ...otherDerivedProps } = useContextSystem( + props, + 'CardBody' + ); // [...] @@ -457,7 +547,7 @@ Please refer to the [JavaScript Testing Overview docs](/docs/contributors/code/t All new components should add stories to the project's [Storybook](https://storybook.js.org/). Each [story](https://storybook.js.org/docs/react/get-started/whats-a-story) captures the rendered state of a UI component in isolation. This greatly simplifies working on a given component, while also serving as an interactive form of documentation. -A component's story should be showcasing its different states — for example, the different variants of a `Button`: +A component's story should be showcasing its different states — for example, the different variants of a `Button`: ```jsx import Button from '../'; @@ -543,6 +633,7 @@ Prop description. With a new line before and after the description and before an Add this section when there are props that are drilled down into an internal component. See [ClipboardButton](/packages/components/src/clipboard-button/README.md) for an example. + ## Context See examples for this section for the [ItemGroup](/packages/components/src/item-group/item-group/README.md#context) and [`Card`](/packages/components/src/card/card/README.md#context) components. @@ -601,8 +692,8 @@ As the needs of the package evolve with time, sometimes we may opt to fully rewr Here is some terminology that will be used in the upcoming sections: -- "Legacy" component: the version(s) of the component that existsted on `trunk` before the rewrite; -- API surface: the component's public APIs. It includes the list of components (and sub-components) exported from the package, their props, any associated React context. It does not include internal classnames and internal DOM structure of the components. +- "Legacy" component: the version(s) of the component that existsted on `trunk` before the rewrite; +- API surface: the component's public APIs. It includes the list of components (and subcomponents) exported from the package, their props, any associated React context. It does not include internal classnames and internal DOM structure of the components. ### Approaches diff --git a/packages/components/src/alignment-matrix-control/cell.tsx b/packages/components/src/alignment-matrix-control/cell.tsx index 162ca879f1a7e5..6e045c26694f4e 100644 --- a/packages/components/src/alignment-matrix-control/cell.tsx +++ b/packages/components/src/alignment-matrix-control/cell.tsx @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { CompositeItem } from '../composite/v2'; +import { Composite } from '../composite'; import Tooltip from '../tooltip'; import { VisuallyHidden } from '../visually-hidden'; @@ -26,7 +26,7 @@ export default function Cell( { return ( - } > @@ -35,7 +35,7 @@ export default function Cell( { hidden element instead of aria-label. */ } { value } - + ); } diff --git a/packages/components/src/alignment-matrix-control/index.tsx b/packages/components/src/alignment-matrix-control/index.tsx index eaec8a285b0c57..1d22c3560625db 100644 --- a/packages/components/src/alignment-matrix-control/index.tsx +++ b/packages/components/src/alignment-matrix-control/index.tsx @@ -13,7 +13,7 @@ import { useInstanceId } from '@wordpress/compose'; * Internal dependencies */ import Cell from './cell'; -import { Composite, CompositeRow, useCompositeStore } from '../composite/v2'; +import { Composite, useCompositeStore } from '../composite'; import { Root, Row } from './styles/alignment-matrix-control-styles'; import AlignmentMatrixControlIcon from './icon'; import { GRID, getItemId, getItemValue } from './utils'; @@ -87,7 +87,7 @@ export function AlignmentMatrixControl( { } > { GRID.map( ( cells, index ) => ( - } key={ index }> + } key={ index }> { cells.map( ( cell ) => { const cellId = getItemId( baseId, cell ); const isActive = cellId === activeId; @@ -101,7 +101,7 @@ export function AlignmentMatrixControl( { /> ); } ) } - + ) ) } ); diff --git a/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts b/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts index bdb015ec64c6a7..efbd23ab2be0af 100644 --- a/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts +++ b/packages/components/src/alignment-matrix-control/styles/alignment-matrix-control-styles.ts @@ -7,7 +7,7 @@ import { css } from '@emotion/react'; /** * Internal dependencies */ -import { COLORS } from '../../utils'; +import { COLORS, CONFIG } from '../../utils'; import type { AlignmentMatrixControlProps, AlignmentMatrixControlCellProps, @@ -15,7 +15,7 @@ import type { export const rootBase = () => { return css` - border-radius: 2px; + border-radius: ${ CONFIG.radiusMedium }; box-sizing: border-box; direction: ltr; display: grid; diff --git a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx index 0141bd860d7df9..f57d60db0744b1 100644 --- a/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx +++ b/packages/components/src/angle-picker-control/styles/angle-picker-control-styles.tsx @@ -15,7 +15,7 @@ const CIRCLE_SIZE = 32; const INNER_CIRCLE_SIZE = 6; export const CircleRoot = styled.div` - border-radius: 50%; + border-radius: ${ CONFIG.radiusRound }; border: ${ CONFIG.borderWidth } solid ${ COLORS.ui.border }; box-sizing: border-box; cursor: grab; @@ -41,7 +41,7 @@ export const CircleIndicatorWrapper = styled.div` export const CircleIndicator = styled.div` background: ${ COLORS.theme.accent }; - border-radius: 50%; + border-radius: ${ CONFIG.radiusRound }; box-sizing: border-box; display: block; left: 50%; diff --git a/packages/components/src/base-control/README.md b/packages/components/src/base-control/README.md index dc3d8c0e29c8e0..d51629de6f7253 100644 --- a/packages/components/src/base-control/README.md +++ b/packages/components/src/base-control/README.md @@ -15,7 +15,7 @@ const MyCustomTextareaControl = ({ children, ...baseProps }) => ( const { baseControlProps, controlProps } = useBaseControlProps( baseProps ); return ( - + @@ -92,7 +92,10 @@ It should only be used in cases where the children being rendered inside BaseCon import { BaseControl } from '@wordpress/components'; const MyBaseControl = () => ( - + Author diff --git a/packages/components/src/base-control/index.tsx b/packages/components/src/base-control/index.tsx index 14ecce1bdd729d..423636a92cd5f0 100644 --- a/packages/components/src/base-control/index.tsx +++ b/packages/components/src/base-control/index.tsx @@ -7,6 +7,7 @@ import type { ForwardedRef } from 'react'; /** * WordPress dependencies */ +import deprecated from '@wordpress/deprecated'; import { forwardRef } from '@wordpress/element'; /** @@ -26,34 +27,12 @@ import { contextConnectWithoutRef, useContextSystem } from '../context'; export { useBaseControlProps } from './hooks'; -/** - * `BaseControl` is a component used to generate labels and help text for components handling user inputs. - * - * ```jsx - * import { BaseControl, useBaseControlProps } from '@wordpress/components'; - * - * // Render a `BaseControl` for a textarea input - * const MyCustomTextareaControl = ({ children, ...baseProps }) => ( - * // `useBaseControlProps` is a convenience hook to get the props for the `BaseControl` - * // and the inner control itself. Namely, it takes care of generating a unique `id`, - * // properly associating it with the `label` and `help` elements. - * const { baseControlProps, controlProps } = useBaseControlProps( baseProps ); - * - * return ( - * - * - * - * ); - * ); - * ``` - */ const UnconnectedBaseControl = ( props: WordPressComponentProps< BaseControlProps, null > ) => { const { __nextHasNoMarginBottom = false, + __associatedWPComponentName = 'BaseControl', id, label, hideLabelFromVision = false, @@ -62,6 +41,17 @@ const UnconnectedBaseControl = ( children, } = useContextSystem( props, 'BaseControl' ); + if ( ! __nextHasNoMarginBottom ) { + deprecated( + `Bottom margin styles for wp.components.${ __associatedWPComponentName }`, + { + since: '6.7', + version: '7.0', + hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.', + } + ); + } + return ( ( - * - * Author - * - * - * ); - */ const UnforwardedVisualLabel = ( props: WordPressComponentProps< BaseControlVisualLabelProps, 'span' >, ref: ForwardedRef< any > @@ -141,9 +114,56 @@ const UnforwardedVisualLabel = ( export const VisualLabel = forwardRef( UnforwardedVisualLabel ); +/** + * `BaseControl` is a component used to generate labels and help text for components handling user inputs. + * + * ```jsx + * import { BaseControl, useBaseControlProps } from '@wordpress/components'; + * + * // Render a `BaseControl` for a textarea input + * const MyCustomTextareaControl = ({ children, ...baseProps }) => ( + * // `useBaseControlProps` is a convenience hook to get the props for the `BaseControl` + * // and the inner control itself. Namely, it takes care of generating a unique `id`, + * // properly associating it with the `label` and `help` elements. + * const { baseControlProps, controlProps } = useBaseControlProps( baseProps ); + * + * return ( + * + * + * + * ); + * ); + * ``` + */ export const BaseControl = Object.assign( contextConnectWithoutRef( UnconnectedBaseControl, 'BaseControl' ), - { VisualLabel } + + { + /** + * `BaseControl.VisualLabel` is used to render a purely visual label inside a `BaseControl` component. + * + * It should only be used in cases where the children being rendered inside `BaseControl` are already accessibly labeled, + * e.g., a button, but we want an additional visual label for that section equivalent to the labels `BaseControl` would + * otherwise use if the `label` prop was passed. + * + * ```jsx + * import { BaseControl } from '@wordpress/components'; + * + * const MyBaseControl = () => ( + * + * Author + * + * + * ); + * ``` + */ + VisualLabel, + } ); export default BaseControl; diff --git a/packages/components/src/base-control/types.ts b/packages/components/src/base-control/types.ts index 07a358d09c14b4..e4c838459209c4 100644 --- a/packages/components/src/base-control/types.ts +++ b/packages/components/src/base-control/types.ts @@ -10,6 +10,13 @@ export type BaseControlProps = { * @default false */ __nextHasNoMarginBottom?: boolean; + /** + * Temporary private prop for showing better deprecation messages, + * e.g. `Some feature from wp.components.${ __associatedWPControl } is deprecated`. + * + * @ignore + */ + __associatedWPComponentName?: string; /** * The HTML `id` of the control element (passed in as a child to `BaseControl`) to which labels and help text are being generated. * This is necessary to accessibly associate the label with that element. diff --git a/packages/components/src/border-control/styles.ts b/packages/components/src/border-control/styles.ts index 28669ebf3ccc78..2c77a2d21465d6 100644 --- a/packages/components/src/border-control/styles.ts +++ b/packages/components/src/border-control/styles.ts @@ -99,7 +99,7 @@ export const colorIndicatorWrapper = ( const { style } = border || {}; return css` - border-radius: 9999px; + border-radius: ${ CONFIG.radiusFull }; border: 2px solid transparent; ${ style ? colorIndicatorBorder( border ) : undefined } width: ${ size === '__unstable-large' ? '24px' : '22px' }; diff --git a/packages/components/src/button-group/style.scss b/packages/components/src/button-group/style.scss index 171722b409f693..96a9e8f458c84c 100644 --- a/packages/components/src/button-group/style.scss +++ b/packages/components/src/button-group/style.scss @@ -12,11 +12,11 @@ } &:first-child { - border-radius: $radius-block-ui 0 0 $radius-block-ui; + border-radius: $radius-small 0 0 $radius-small; } &:last-child { - border-radius: 0 $radius-block-ui $radius-block-ui 0; + border-radius: 0 $radius-small $radius-small 0; } // The focused button should be elevated so the focus ring isn't cropped, diff --git a/packages/components/src/button/style.scss b/packages/components/src/button/style.scss index 7d67dcc0748b85..444e4d397b3ef8 100644 --- a/packages/components/src/button/style.scss +++ b/packages/components/src/button/style.scss @@ -22,7 +22,7 @@ align-items: center; box-sizing: border-box; padding: 6px 12px; - border-radius: $radius-block-ui; + border-radius: $radius-small; color: $components-color-foreground; &.is-next-40px-default-size { @@ -249,7 +249,7 @@ height: auto; &:focus { - border-radius: $radius-block-ui; + border-radius: $radius-small; } &:disabled, diff --git a/packages/components/src/checkbox-control/index.tsx b/packages/components/src/checkbox-control/index.tsx index 51e232d97847a9..a24d1abaab45a7 100644 --- a/packages/components/src/checkbox-control/index.tsx +++ b/packages/components/src/checkbox-control/index.tsx @@ -95,6 +95,7 @@ export function CheckboxControl( return ( = DefaultTemplate.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, label: 'Is author', help: 'Is the user an author or not?', }; diff --git a/packages/components/src/checkbox-control/test/index.tsx b/packages/components/src/checkbox-control/test/index.tsx index 899a9b100015b3..547f479184e862 100644 --- a/packages/components/src/checkbox-control/test/index.tsx +++ b/packages/components/src/checkbox-control/test/index.tsx @@ -20,13 +20,20 @@ const noop = () => {}; const getInput = () => screen.getByRole( 'checkbox' ) as HTMLInputElement; const CheckboxControl = ( props: Omit< CheckboxControlProps, 'onChange' > ) => { - return ; + return ( + + ); }; const ControlledCheckboxControl = ( { onChange }: CheckboxControlProps ) => { const [ isChecked, setChecked ] = useState( false ); return ( { setChecked( value ); diff --git a/packages/components/src/circular-option-picker/circular-option-picker-option.tsx b/packages/components/src/circular-option-picker/circular-option-picker-option.tsx index 6c00a0e5d0bf1a..35a2f427134f40 100644 --- a/packages/components/src/circular-option-picker/circular-option-picker-option.tsx +++ b/packages/components/src/circular-option-picker/circular-option-picker-option.tsx @@ -16,9 +16,9 @@ import { Icon, check } from '@wordpress/icons'; */ import { CircularOptionPickerContext } from './circular-option-picker-context'; import Button from '../button'; -import { CompositeItem } from '../composite/v2'; +import { Composite } from '../composite'; import Tooltip from '../tooltip'; -import type { OptionProps, CircularOptionPickerCompositeStore } from './types'; +import type { OptionProps } from './types'; function UnforwardedOptionAsButton( props: { @@ -45,7 +45,9 @@ function UnforwardedOptionAsOption( id: string; className?: string; isSelected?: boolean; - compositeStore: CircularOptionPickerCompositeStore; + compositeStore: NonNullable< + React.ComponentProps< typeof Composite >[ 'store' ] + >; }, forwardedRef: ForwardedRef< any > ) { @@ -57,7 +59,7 @@ function UnforwardedOptionAsOption( } return ( - } - store={ compositeStore } id={ id } /> ); diff --git a/packages/components/src/circular-option-picker/circular-option-picker.tsx b/packages/components/src/circular-option-picker/circular-option-picker.tsx index cd2ddcf90d7ce0..c1e719f2d4f665 100644 --- a/packages/components/src/circular-option-picker/circular-option-picker.tsx +++ b/packages/components/src/circular-option-picker/circular-option-picker.tsx @@ -13,7 +13,7 @@ import { isRTL } from '@wordpress/i18n'; * Internal dependencies */ import { CircularOptionPickerContext } from './circular-option-picker-context'; -import { Composite, useCompositeStore } from '../composite/v2'; +import { Composite, useCompositeStore } from '../composite'; import type { CircularOptionPickerProps, ListboxCircularOptionPickerProps, diff --git a/packages/components/src/circular-option-picker/style.scss b/packages/components/src/circular-option-picker/style.scss index 33ba6070dde799..74a380e0936be8 100644 --- a/packages/components/src/circular-option-picker/style.scss +++ b/packages/components/src/circular-option-picker/style.scss @@ -70,7 +70,7 @@ $color-palette-circle-spacing: 12px; height: 100%; width: 100%; border: none; - border-radius: 50%; + border-radius: $radius-round; background: transparent; box-shadow: inset 0 0 0 ($color-palette-circle-size * 0.5); transition: 100ms box-shadow ease; @@ -93,7 +93,7 @@ $color-palette-circle-spacing: 12px; position: absolute; left: 2px; top: 2px; - border-radius: 50%; + border-radius: $radius-round; z-index: z-index(".components-circular-option-picker__option.is-pressed + svg"); pointer-events: none; } diff --git a/packages/components/src/circular-option-picker/types.ts b/packages/components/src/circular-option-picker/types.ts index 519d81d5905107..e23ff4165f0580 100644 --- a/packages/components/src/circular-option-picker/types.ts +++ b/packages/components/src/circular-option-picker/types.ts @@ -14,7 +14,7 @@ import type { Icon } from '@wordpress/icons'; import type { ButtonAsButtonProps } from '../button/types'; import type { DropdownProps } from '../dropdown/types'; import type { WordPressComponentProps } from '../context'; -import type { CompositeStore } from '../composite/v2'; +import type { Composite } from '../composite'; type CommonCircularOptionPickerProps = { /** @@ -123,8 +123,7 @@ export type OptionProps = Omit< >; }; -export type CircularOptionPickerCompositeStore = CompositeStore; export type CircularOptionPickerContextProps = { baseId?: string; - compositeStore?: CircularOptionPickerCompositeStore; + compositeStore?: React.ComponentProps< typeof Composite >[ 'store' ]; }; diff --git a/packages/components/src/color-indicator/style.scss b/packages/components/src/color-indicator/style.scss index e70b8a09ca5bf5..4029b50340e62a 100644 --- a/packages/components/src/color-indicator/style.scss +++ b/packages/components/src/color-indicator/style.scss @@ -2,7 +2,7 @@ width: $grid-unit-50 * 0.5; height: $grid-unit-50 * 0.5; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2); - border-radius: 50%; + border-radius: $radius-round; display: inline-block; padding: 0; background: $white linear-gradient(-45deg, transparent 48%, $gray-300 48%, $gray-300 52%, transparent 52%); diff --git a/packages/components/src/color-palette/style.scss b/packages/components/src/color-palette/style.scss index 2d6bc4ddc1db3d..9d922a8130692a 100644 --- a/packages/components/src/color-palette/style.scss +++ b/packages/components/src/color-palette/style.scss @@ -14,7 +14,7 @@ $border-as-box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); cursor: pointer; // Show a thin outline in Windows high contrast mode. outline: 1px solid transparent; - border-radius: $radius-block-ui $radius-block-ui 0 0; + border-radius: $radius-medium $radius-medium 0 0; box-shadow: $border-as-box-shadow; &:focus { @@ -46,7 +46,7 @@ $border-as-box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.2); .components-color-palette__custom-color-text-wrapper { padding: $grid-unit-15 $grid-unit-20; - border-radius: 0 0 $radius-block-ui $radius-block-ui; + border-radius: 0 0 $radius-medium $radius-medium; position: relative; font-size: $default-font-size; diff --git a/packages/components/src/color-picker/input-with-slider.tsx b/packages/components/src/color-picker/input-with-slider.tsx index b51db75c1f4513..535adc3f2af6ee 100644 --- a/packages/components/src/color-picker/input-with-slider.tsx +++ b/packages/components/src/color-picker/input-with-slider.tsx @@ -53,6 +53,7 @@ export const InputWithSlider = ( { /> = ( { }; export const Default = Template.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, allowReset: false, label: 'Select a country', options: countryOptions, @@ -135,8 +136,7 @@ const optionsWithDisabledOptions = countryOptions.map( ( option, index ) => ( { } ) ); WithDisabledOptions.args = { - allowReset: false, - label: 'Select a country', + ...Default.args, options: optionsWithDisabledOptions, }; @@ -148,8 +148,7 @@ WithDisabledOptions.args = { export const NotExpandOnFocus = Template.bind( {} ); NotExpandOnFocus.args = { - allowReset: false, - label: 'Select a country', + ...Default.args, options: countryOptions, expandOnFocus: false, }; diff --git a/packages/components/src/combobox-control/test/index.tsx b/packages/components/src/combobox-control/test/index.tsx index 76ce9cc4724c54..adc76590c24538 100644 --- a/packages/components/src/combobox-control/test/index.tsx +++ b/packages/components/src/combobox-control/test/index.tsx @@ -12,7 +12,7 @@ import { useState } from '@wordpress/element'; /** * Internal dependencies */ -import ComboboxControl from '..'; +import _ComboboxControl from '..'; import type { ComboboxControlOption, ComboboxControlProps } from '../types'; const timezones = [ @@ -57,6 +57,10 @@ const getAllOptions = () => screen.getAllByRole( 'option' ); const getOptionSearchString = ( option: ComboboxControlOption ) => option.label.substring( 0, 11 ); +const ComboboxControl = ( props: ComboboxControlProps ) => { + return <_ComboboxControl { ...props } __nextHasNoMarginBottom />; +}; + const ControlledComboboxControl = ( { value: valueProp, onChange, diff --git a/packages/components/src/composite/README.md b/packages/components/src/composite/README.md new file mode 100644 index 00000000000000..35881d815cf1bc --- /dev/null +++ b/packages/components/src/composite/README.md @@ -0,0 +1,325 @@ +# `Composite` + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +`Composite` provides a single tab stop on the page and allows navigation through the focusable descendants with arrow keys. This abstract component is based on the [WAI-ARIA Composite Role⁠](https://w3c.github.io/aria/#composite). + +## Usage + +```jsx +import { Composite, useCompositeStore } from '@wordpress/components'; + +const store = useCompositeStore(); + + + Label + Item 1 + Item 2 + + +``` + +## Hooks + +### `useCompositeStore` + +Creates a composite store. + +#### Props + +##### `activeId`: `string | null` + +The current active item `id`. The active item is the element within the composite widget that has either DOM or virtual focus (in case the `virtualFocus` prop is enabled). + +- `null` represents the base composite element (the one with a [composite role](https://w3c.github.io/aria/#composite)). Users will be able to navigate out of it using arrow keys. +- If `activeId` is initially set to `null`, the base composite element itself will have focus and users will be able to navigate to it using arrow keys. + +- Required: no + +##### `defaultActiveId`: `string | null` + +The composite item id that should be active by default when the composite widget is rendered. If `null`, the composite element itself will have focus and users will be able to navigate to it using arrow keys. If `undefined`, the first enabled item will be focused. + +- Required: no + +##### `setActiveId`: `((activeId: string | null | undefined) => void)` + +A callback that gets called when the `activeId` state changes. + +- Required: no + +##### `focusLoop`: `boolean | 'horizontal' | 'vertical' | 'both'` + +Determines how the focus behaves when the user reaches the end of the composite widget. + +On one-dimensional composite widgets: + +- `true` loops from the last item to the first item and vice-versa. +- `horizontal` loops only if `orientation` is `horizontal` or not set. +- `vertical` loops only if `orientation` is `vertical` or not set. +- If `activeId` is initially set to `null`, the composite element will be focused in between the last and first items. + +On two-dimensional composite widgets (ie. when using `CompositeRow`): + +- `true` loops from the last row/column item to the first item in the same row/column and vice-versa. If it's the last item in the last row, it moves to the first item in the first row and vice-versa. +- `horizontal` loops only from the last row item to the first item in the same row. +- `vertical` loops only from the last column item to the first item in the column row. +- If `activeId` is initially set to `null`, vertical loop will have no effect as moving down from the last row or up from the first row will focus on the composite element. +- If `focusWrap` matches the value of `focusLoop`, it'll wrap between the last item in the last row or column and the first item in the first row or column and vice-versa. + +- Required: no +- Default: `false` + +##### `focusShift`: `boolean` + +**Works only on two-dimensional composite widgets**. + +If enabled, moving up or down when there's no next item or when the next item is disabled will shift to the item right before it. + +- Required: no +- Default: `false` + +##### `focusWrap`: `boolean` + +**Works only on two-dimensional composite widgets**. + +If enabled, moving to the next item from the last one in a row or column +will focus on the first item in the next row or column and vice-versa. + +- `true` wraps between rows and columns. +- `horizontal` wraps only between rows. +- `vertical` wraps only between columns. +- If `focusLoop` matches the value of `focusWrap`, it'll wrap between the + last item in the last row or column and the first item in the first row or + column and vice-versa. + +- Required: no +- Default: `false` + +##### `virtualFocus`: `boolean` + +If enabled, the composite element will act as an [`aria-activedescendant`](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant) +container instead of [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex). DOM focus will remain on the composite element while its items receive +virtual focus. + +In both scenarios, the item in focus will carry the `data-active-item` attribute. + +- Required: no +- Default: `false` + +##### `orientation`: `'horizontal' | 'vertical' | 'both'` + +Defines the orientation of the composite widget. If the composite has a single row or column (one-dimensional), the `orientation` value determines which arrow keys can be used to move focus: + +- `both`: all arrow keys work. +- `horizontal`: only left and right arrow keys work. +- `vertical`: only up and down arrow keys work. + +It doesn't have any effect on two-dimensional composites. + +- Required: no +- Default: `both` + +##### `rtl`: `boolean` + +Determines how the `store`'s `next` and `previous` functions will behave. If `rtl` is set to `true`, they will be inverted. + +This only affects the composite widget behavior. You still need to set `dir="rtl"` on HTML/CSS. + +- Required: no +- Default: `false` + +## Components + +### `Composite` + +Renders a composite widget. + +#### Props + +##### `store`: `CompositeStore` + +Object returned by the `useCompositeStore` hook. + +- Required: yes + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `focusable`: `boolean` + +Makes the component a focusable element. When this element gains keyboard focus, it gets a `data-focus-visible` attribute and triggers the `onFocusVisible` prop. + +The component supports the `disabled` prop even for those elements not supporting the native `disabled` attribute. Disabled elements may be still accessible via keyboard by using the the `accessibleWhenDisabled` prop. + +Non-native focusable elements will lose their focusability entirely. However, native focusable elements will retain their inherent focusability. + +- Required: no + +##### `disabled`: `boolean` + +Determines if the element is disabled. This sets the `aria-disabled` attribute accordingly, enabling support for all elements, including those that don't support the native `disabled` attribute. + +This feature can be combined with the `accessibleWhenDisabled` prop to +make disabled elements still accessible via keyboard. + +**Note**: For this prop to work, the `focusable` prop must be set to +`true`, if it's not set by default. + +- Required: no +- Default: `false` + +##### `accessibleWhenDisabled`: `boolean` + +Indicates whether the element should be focusable even when it is +`disabled`. + +This is important when discoverability is a concern. For example: + +> A toolbar in an editor contains a set of special smart paste functions +> that are disabled when the clipboard is empty or when the function is not +> applicable to the current content of the clipboard. It could be helpful to +> keep the disabled buttons focusable if the ability to discover their +> functionality is primarily via their presence on the toolbar. + +Learn more on [Focusability of disabled +controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols). + +- Required: no + +##### `onFocusVisible`: `(event: SyntheticEvent) => void` + +Custom event handler invoked when the element gains focus through keyboard interaction or a key press occurs while the element is in focus. This is the programmatic equivalent of the `data-focus-visible` attribute. + +**Note**: For this prop to work, the `focusable` prop must be set to `true` if it's not set by default. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Group` + +Renders a group element for composite items. + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.GroupLabel` + +Renders a label in a composite group. This component must be wrapped with `Composite.Group` so the `aria-labelledby` prop is properly set on the composite group element. + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Item` + +Renders a composite item. + +##### `accessibleWhenDisabled`: `boolean` + +Indicates whether the element should be focusable even when it is +`disabled`. + +This is important when discoverability is a concern. For example: + +> A toolbar in an editor contains a set of special smart paste functions +> that are disabled when the clipboard is empty or when the function is not +> applicable to the current content of the clipboard. It could be helpful to +> keep the disabled buttons focusable if the ability to discover their +> functionality is primarily via their presence on the toolbar. + +Learn more on [Focusability of disabled +controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols). + +- Required: no + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Row` + +Renders a composite row. Wrapping `Composite.Item` elements within `Composite.Row` will create a two-dimensional composite widget, such as a grid. + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Hover` + +Renders an element in a composite widget that receives focus on mouse move and loses focus to the composite base element on mouse leave. This should be combined with the `Composite.Item` component. + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Typeahead` + +Renders a component that adds typeahead functionality to composite components. Hitting printable character keys will move focus to the next composite item that begins with the input characters. + +##### `render`: `RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>` + +Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged. + +- Required: no + +##### `children`: `React.ReactNode` + +The contents of the component. + +- Required: no + +### `Composite.Context` + +The React context used by the composite components. It can be used by to access the composite store, and to forward the context when composite sub-components are rendered across portals (ie. `SlotFill` components) that would not otherwise forward the context to the `Fill` children. diff --git a/packages/components/src/composite/context.ts b/packages/components/src/composite/context.ts new file mode 100644 index 00000000000000..69a052c5bfba19 --- /dev/null +++ b/packages/components/src/composite/context.ts @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { CompositeContextProps } from './types'; + +export const CompositeContext = + createContext< CompositeContextProps >( undefined ); + +export const useCompositeContext = () => useContext( CompositeContext ); diff --git a/packages/components/src/composite/current/index.ts b/packages/components/src/composite/current/index.ts deleted file mode 100644 index 96379f00296516..00000000000000 --- a/packages/components/src/composite/current/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Composite is a component that may contain navigable items represented by - * CompositeItem. It's inspired by the WAI-ARIA Composite Role and implements - * all the keyboard navigation mechanisms to ensure that there's only one - * tab stop for the whole Composite element. This means that it can behave as - * a roving tabindex or aria-activedescendant container. - * - * @see https://ariakit.org/components/composite - */ - -export { - Composite, - CompositeGroup, - CompositeGroupLabel, - CompositeItem, - CompositeRow, - useCompositeStore, -} from '@ariakit/react'; - -export type { CompositeStore, CompositeStoreProps } from '@ariakit/react'; diff --git a/packages/components/src/composite/current/stories/index.story.tsx b/packages/components/src/composite/current/stories/index.story.tsx deleted file mode 100644 index 335ebc3244c918..00000000000000 --- a/packages/components/src/composite/current/stories/index.story.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * External dependencies - */ -import type { Meta, StoryFn } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { isRTL } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { - Composite, - CompositeGroup, - CompositeRow, - CompositeItem, - useCompositeStore, -} from '..'; -import { UseCompositeStorePlaceholder, transform } from './utils'; - -const meta: Meta< typeof UseCompositeStorePlaceholder > = { - title: 'Components/Composite (V2)', - component: UseCompositeStorePlaceholder, - subcomponents: { - // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 - Composite, - // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 - CompositeGroup, - // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 - CompositeRow, - // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 - CompositeItem, - }, - tags: [ 'status-private' ], - parameters: { - docs: { - canvas: { sourceState: 'shown' }, - source: { transform }, - extractArgTypes: ( component: React.FunctionComponent ) => { - const name = component.displayName; - const path = name - ?.replace( - /([a-z])([A-Z])/g, - ( _, a, b ) => `${ a }-${ b.toLowerCase() }` - ) - .toLowerCase(); - const url = `https://ariakit.org/reference/${ path }`; - return { - props: { - name: 'Props', - description: `See Ariakit docs for ${ name }`, - table: { type: { summary: undefined } }, - }, - }; - }, - }, - }, -}; -export default meta; - -export const Default: StoryFn< typeof Composite > = ( { ...initialState } ) => { - const rtl = isRTL(); - const store = useCompositeStore( { rtl, ...initialState } ); - - return ( - - - Item A1 - Item A2 - Item A3 - - - Item B1 - Item B2 - Item B3 - - - Item C1 - Item C2 - Item C3 - - - ); -}; diff --git a/packages/components/src/composite/index.ts b/packages/components/src/composite/index.ts deleted file mode 100644 index aa06a6adf36ef2..00000000000000 --- a/packages/components/src/composite/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Originally this pointed at a Reakit implementation of -// `Composite`, but we are removing Reakit entirely from the -// codebase. We will continue to support the Reakit API -// through the 'legacy' version, which uses Ariakit under -// the hood. - -export * from './legacy'; diff --git a/packages/components/src/composite/index.tsx b/packages/components/src/composite/index.tsx new file mode 100644 index 00000000000000..0bfcec2bf76600 --- /dev/null +++ b/packages/components/src/composite/index.tsx @@ -0,0 +1,341 @@ +/** + * Composite is a component that may contain navigable items represented by + * Composite.Item. It's inspired by the WAI-ARIA Composite Role and implements + * all the keyboard navigation mechanisms to ensure that there's only one + * tab stop for the whole Composite element. This means that it can behave as + * a roving tabindex or aria-activedescendant container. + * + * @see https://ariakit.org/components/composite + */ + +/** + * External dependencies + */ +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import { useMemo, forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { WordPressComponentProps } from '../context'; +import { CompositeContext, useCompositeContext } from './context'; +import type { + CompositeStoreProps, + CompositeProps, + CompositeGroupProps, + CompositeGroupLabelProps, + CompositeItemProps, + CompositeRowProps, + CompositeHoverProps, + CompositeTypeaheadProps, +} from './types'; + +/** + * Creates a composite store. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * Item + * Item + * Item + * + * ``` + */ +export function useCompositeStore( { + focusLoop = false, + focusWrap = false, + focusShift = false, + virtualFocus = false, + orientation = 'both', + rtl = false, + ...props +}: CompositeStoreProps = {} ) { + return Ariakit.useCompositeStore( { + focusLoop, + focusWrap, + focusShift, + virtualFocus, + orientation, + rtl, + ...props, + } ); +} + +const Group = forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeGroupProps, 'div', false > +>( function CompositeGroup( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +Group.displayName = 'Composite.Group'; + +const GroupLabel = forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeGroupLabelProps, 'div', false > +>( function CompositeGroupLabel( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +GroupLabel.displayName = 'Composite.GroupLabel'; + +const Item = forwardRef< + HTMLButtonElement, + WordPressComponentProps< CompositeItemProps, 'button', false > +>( function CompositeItem( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +Item.displayName = 'Composite.Item'; + +const Row = forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeRowProps, 'div', false > +>( function CompositeRow( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +Row.displayName = 'Composite.Row'; + +const Hover = forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeHoverProps, 'div', false > +>( function CompositeHover( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +Hover.displayName = 'Composite.Hover'; + +const Typeahead = forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeTypeaheadProps, 'div', false > +>( function CompositeTypeahead( props, ref ) { + const context = useCompositeContext(); + return ( + + ); +} ); +Typeahead.displayName = 'Composite.Typeahead'; + +/** + * Renders a widget based on the WAI-ARIA [`composite`](https://w3c.github.io/aria/#composite) + * role, which provides a single tab stop on the page and arrow key navigation + * through the focusable descendants. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * Item 1 + * Item 2 + * + * ``` + */ +export const Composite = Object.assign( + forwardRef< + HTMLDivElement, + WordPressComponentProps< CompositeProps, 'div', false > + >( function Composite( + { children, store, disabled = false, ...props }, + ref + ) { + const contextValue = useMemo( + () => ( { + store, + } ), + [ store ] + ); + + return ( + + + { children } + + + ); + } ), + { + displayName: 'Composite', + /** + * Renders a group element for composite items. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * + * Label + * Item 1 + * Item 2 + * + * + * ``` + */ + Group, + /** + * Renders a label in a composite group. This component must be wrapped with + * `Composite.Group` so the `aria-labelledby` prop is properly set on the + * composite group element. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * + * Label + * Item 1 + * Item 2 + * + * + * ``` + */ + GroupLabel, + /** + * Renders a composite item. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * Item 1 + * Item 2 + * Item 3 + * + * ``` + */ + Item, + /** + * Renders a composite row. Wrapping `Composite.Item` elements within + * `Composite.Row` will create a two-dimensional composite widget, such as a + * grid. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * + * Item 1.1 + * Item 1.2 + * Item 1.3 + * + * + * Item 2.1 + * Item 2.2 + * Item 2.3 + * + * + * ``` + */ + Row, + /** + * Renders an element in a composite widget that receives focus on mouse move + * and loses focus to the composite base element on mouse leave. This should + * be combined with the `Composite.Item` component. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * }> + * Item 1 + * + * }> + * Item 2 + * + * + * ``` + */ + Hover, + /** + * Renders a component that adds typeahead functionality to composite + * components. Hitting printable character keys will move focus to the next + * composite item that begins with the input characters. + * + * @example + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * }> + * Item 1 + * Item 2 + * + * ``` + */ + Typeahead, + /** + * The React context used by the composite components. It can be used by + * to access the composite store, and to forward the context when composite + * sub-components are rendered across portals (ie. `SlotFill` components) + * that would not otherwise forward the context to the `Fill` children. + * + * @example + * ```jsx + * import { Composite } from '@wordpress/components'; + * import { useContext } from '@wordpress/element'; + * + * const compositeContext = useContext( Composite.Context ); + * ``` + */ + Context: CompositeContext, + } +); diff --git a/packages/components/src/composite/legacy/index.tsx b/packages/components/src/composite/legacy/index.tsx index 5c5c674b5086b8..dffdc1a2066d47 100644 --- a/packages/components/src/composite/legacy/index.tsx +++ b/packages/components/src/composite/legacy/index.tsx @@ -5,6 +5,11 @@ * tab stop for the whole Composite element. This means that it can behave as * a roving tabindex or aria-activedescendant container. * + * This file aims at providing components that are as close as possible to the + * original `reakit`-based implementation (which was removed from the codebase), + * although it is recommended that consumers of the package switch to the stable, + * un-prefixed, `ariakit`-based version of `Composite`. + * * @see https://ariakit.org/components/composite */ @@ -16,7 +21,7 @@ import { forwardRef } from '@wordpress/element'; /** * Internal dependencies */ -import * as Current from '../current'; +import { Composite as Current, useCompositeStore } from '..'; import { useInstanceId } from '@wordpress/compose'; type Orientation = 'horizontal' | 'vertical'; @@ -73,7 +78,7 @@ export interface LegacyStateOptions { type Component = React.FunctionComponent< any >; -type CompositeStore = ReturnType< typeof Current.useCompositeStore >; +type CompositeStore = ReturnType< typeof useCompositeStore >; type CompositeStoreState = { store: CompositeStore }; export type CompositeState = CompositeStoreState & Required< Pick< LegacyStateOptions, 'baseId' > >; @@ -93,9 +98,9 @@ type CompositeComponent< C extends Component > = ( ) => React.ReactElement; type CompositeComponentProps = CompositeState & ( - | ComponentProps< typeof Current.CompositeGroup > - | ComponentProps< typeof Current.CompositeItem > - | ComponentProps< typeof Current.CompositeRow > + | ComponentProps< typeof Current.Group > + | ComponentProps< typeof Current.Item > + | ComponentProps< typeof Current.Row > ); function mapLegacyStatePropsToComponentProps( @@ -145,19 +150,15 @@ function proxyComposite< C extends Component >( // provided role, and returning the appropriate component. const unproxiedCompositeGroup = forwardRef< any, - React.ComponentPropsWithoutRef< - typeof Current.CompositeGroup | typeof Current.CompositeRow - > + React.ComponentPropsWithoutRef< typeof Current.Group | typeof Current.Row > >( ( { role, ...props }, ref ) => { - const Component = - role === 'row' ? Current.CompositeRow : Current.CompositeGroup; + const Component = role === 'row' ? Current.Row : Current.Group; return ; } ); -unproxiedCompositeGroup.displayName = 'CompositeGroup'; -export const Composite = proxyComposite( Current.Composite, { baseId: 'id' } ); +export const Composite = proxyComposite( Current, { baseId: 'id' } ); export const CompositeGroup = proxyComposite( unproxiedCompositeGroup ); -export const CompositeItem = proxyComposite( Current.CompositeItem, { +export const CompositeItem = proxyComposite( Current.Item, { focusable: 'accessibleWhenDisabled', } ); @@ -178,7 +179,7 @@ export function useCompositeState( return { baseId: useInstanceId( Composite, 'composite', baseId ), - store: Current.useCompositeStore( { + store: useCompositeStore( { defaultActiveId, rtl, orientation, diff --git a/packages/components/src/composite/legacy/stories/utils.tsx b/packages/components/src/composite/legacy/stories/utils.tsx index 06edd348634695..2fb51c845f9fbe 100644 --- a/packages/components/src/composite/legacy/stories/utils.tsx +++ b/packages/components/src/composite/legacy/stories/utils.tsx @@ -8,6 +8,25 @@ import type { StoryContext } from '@storybook/react'; */ import type { LegacyStateOptions } from '..'; +/** + * Renders a composite widget. + * + * This unstable component is deprecated. Use `Composite` instead. + * + * ```jsx + * import { + * __unstableUseCompositeState as useCompositeState, + * __unstableComposite as Composite, + * __unstableCompositeItem as CompositeItem, + * } from '@wordpress/components'; + * + * const state = useCompositeState(); + * + * Item 1 + * Item 2 + * ; + * ``` + */ export function UseCompositeStatePlaceholder( props: LegacyStateOptions ) { return (
diff --git a/packages/components/src/composite/stories/index.story.tsx b/packages/components/src/composite/stories/index.story.tsx new file mode 100644 index 00000000000000..034e1d6721f7bd --- /dev/null +++ b/packages/components/src/composite/stories/index.story.tsx @@ -0,0 +1,466 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { isRTL } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Composite, useCompositeStore } from '..'; +import { UseCompositeStorePlaceholder, transform } from './utils'; + +const meta: Meta< typeof UseCompositeStorePlaceholder > = { + title: 'Components/Composite (V2)', + component: UseCompositeStorePlaceholder, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Composite, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.Group': Composite.Group, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.GroupLabel': Composite.GroupLabel, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.Row': Composite.Row, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.Item': Composite.Item, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.Hover': Composite.Hover, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + 'Composite.Typeahead': Composite.Typeahead, + }, + argTypes: { + activeId: { control: 'text' }, + defaultActiveId: { control: 'text' }, + setActiveId: { control: { type: null } }, + focusLoop: { + control: 'select', + options: [ true, false, 'horizontal', 'vertical', 'both' ], + }, + focusShift: { control: 'boolean' }, + focusWrap: { control: 'boolean' }, + virtualFocus: { control: 'boolean' }, + rtl: { control: 'boolean' }, + orientation: { + control: 'select', + options: [ 'horizontal', 'vertical', 'both' ], + }, + }, + tags: [ 'status-private' ], + parameters: { + controls: { expanded: true }, + docs: { + canvas: { sourceState: 'shown' }, + source: { transform }, + extractArgTypes: ( component: React.FunctionComponent ) => { + const commonArgTypes = { + render: { + name: 'render', + description: + 'Allows the component to be rendered as a different HTML element or React component. The value can be a React element or a function that takes in the original component props and gives back a React element with the props merged.', + table: { + type: { + summary: + 'RenderProp & { ref?: React.Ref | undefined; }> | React.ReactElement>', + }, + }, + }, + children: { + name: 'children', + description: 'The contents of the component.', + table: { type: { summary: 'React.ReactNode' } }, + }, + }; + const accessibleWhenDisabled = { + name: 'accessibleWhenDisabled', + description: `Indicates whether the element should be focusable even when it is +\`disabled\`. + +This is important when discoverability is a concern. For example: + +> A toolbar in an editor contains a set of special smart paste functions +> that are disabled when the clipboard is empty or when the function is not +> applicable to the current content of the clipboard. It could be helpful to +> keep the disabled buttons focusable if the ability to discover their +> functionality is primarily via their presence on the toolbar. + +Learn more on [Focusability of disabled +controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols).`, + table: { + type: { + summary: 'boolean', + }, + }, + }; + + const argTypes = { + useCompositeStore: { + activeId: { + name: 'activeId', + description: `The current active item \`id\`. The active item is the element within the composite widget that has either DOM or virtual focus (in case the \`virtualFocus\` prop is enabled). +- \`null\` represents the base composite element (the one with a [composite role](https://w3c.github.io/aria/#composite)). Users will be able to navigate out of it using arrow keys. +- If \`activeId\` is initially set to \`null\`, the base composite element itself will have focus and users will be able to navigate to it using arrow keys.`, + table: { type: { summary: 'string | null' } }, + }, + defaultActiveId: { + name: 'defaultActiveId', + description: + 'The composite item id that should be active by default when the composite widget is rendered. If `null`, the composite element itself will have focus and users will be able to navigate to it using arrow keys. If `undefined`, the first enabled item will be focused.', + table: { type: { summary: 'string | null' } }, + }, + setActiveId: { + name: 'setActiveId', + description: + 'A callback that gets called when the `activeId` state changes.', + table: { + type: { + summary: + '((activeId: string | null | undefined) => void)', + }, + }, + }, + focusLoop: { + name: 'focusLoop', + description: `On one-dimensional composite widgets: + +- \`true\` loops from the last item to the first item and vice-versa. +- \`horizontal\` loops only if \`orientation\` is \`horizontal\` or not set. +- \`vertical\` loops only if \`orientation\` is \`vertical\` or not set. +- If \`activeId\` is initially set to \`null\`, the composite element will be focused in between the last and first items. + +On two-dimensional composite widgets (ie. when using \`CompositeRow\`): + +- \`true\` loops from the last row/column item to the first item in the same row/column and vice-versa. If it's the last item in the last row, it moves to the first item in the first row and vice-versa. +- \`horizontal\` loops only from the last row item to the first item in the same row. +- \`vertical\` loops only from the last column item to the first item in the column row. +- If \`activeId\` is initially set to \`null\`, vertical loop will have no effect as moving down from the last row or up from the first row will focus on the composite element. +- If \`focusWrap\` matches the value of \`focusLoop\`, it'll wrap between the last item in the last row or column and the first item in the first row or column and vice-versa.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: + "boolean | 'horizontal' | 'vertical' | 'both'", + }, + }, + }, + focusShift: { + name: 'focusShift', + description: `**Works only on two-dimensional composite widgets**. + +If enabled, moving up or down when there's no next item or when the next item is disabled will shift to the item right before it.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: 'boolean', + }, + }, + }, + focusWrap: { + name: 'focusWrap', + description: `**Works only on two-dimensional composite widgets**. + +If enabled, moving to the next item from the last one in a row or column +will focus on the first item in the next row or column and vice-versa. + +- \`true\` wraps between rows and columns. +- \`horizontal\` wraps only between rows. +- \`vertical\` wraps only between columns. +- If \`focusLoop\` matches the value of \`focusWrap\`, it'll wrap between the + last item in the last row or column and the first item in the first row or + column and vice-versa.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: 'boolean', + }, + }, + }, + virtualFocus: { + name: 'virtualFocus', + description: `If enabled, the composite element will act as an [\`aria-activedescendant\`](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant) +container instead of [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex). DOM focus will remain on the composite element while its items receive +virtual focus. + +In both scenarios, the item in focus will carry the \`data-active-item\` attribute.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: 'boolean', + }, + }, + }, + orientation: { + name: 'orientation', + description: `Defines the orientation of the composite widget. If the composite has a single row or column (one-dimensional), the \`orientation\` value determines which arrow keys can be used to move focus: + +- \`both\`: all arrow keys work. +- \`horizontal\`: only left and right arrow keys work. +- \`vertical\`: only up and down arrow keys work. + +It doesn't have any effect on two-dimensional composites.`, + table: { + defaultValue: { + summary: "'both'", + }, + type: { + summary: + "'horizontal' | 'vertical' | 'both'", + }, + }, + }, + rtl: { + name: 'rtl', + description: `Determines how the \`store\`'s \`next\` and \`previous\` functions will behave. If \`rtl\` is set to \`true\`, they will be inverted. + +This only affects the composite widget behavior. You still need to set \`dir="rtl"\` on HTML/CSS.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: 'boolean', + }, + }, + }, + }, + Composite: { + ...commonArgTypes, + store: { + name: 'store', + description: + 'Object returned by the `useCompositeStore` hook.', + table: { + type: { + summary: + 'CompositeStore', + }, + }, + type: { required: true }, + }, + focusable: { + name: 'focusable', + description: `Makes the component a focusable element. When this element gains keyboard focus, it gets a \`data-focus-visible\` attribute and triggers the \`onFocusVisible\` prop. + +The component supports the \`disabled\` prop even for those elements not supporting the native \`disabled\` attribute. Disabled elements may be still accessible via keyboard by using the the \`accessibleWhenDisabled\` prop. + +Non-native focusable elements will lose their focusability entirely. However, native focusable elements will retain their inherent focusability.`, + table: { + type: { + summary: 'boolean', + }, + }, + }, + disabled: { + name: 'disabled', + description: `Determines if the element is disabled. This sets the \`aria-disabled\` attribute accordingly, enabling support for all elements, including those that don't support the native \`disabled\` attribute. + +This feature can be combined with the \`accessibleWhenDisabled\` prop to +make disabled elements still accessible via keyboard. + +**Note**: For this prop to work, the \`focusable\` prop must be set to +\`true\`, if it's not set by default.`, + table: { + defaultValue: { + summary: 'false', + }, + type: { + summary: 'boolean', + }, + }, + }, + accessibleWhenDisabled, + onFocusVisible: { + name: 'onFocusVisible', + description: `Custom event handler invoked when the element gains focus through keyboard interaction or a key press occurs while the element is in focus. This is the programmatic equivalent of the \`data-focus-visible\` attribute. + +**Note**: For this prop to work, the \`focusable\` prop must be set to \`true\` if it's not set by default.`, + table: { + type: { + summary: + '(event: SyntheticEvent) => void', + }, + }, + }, + }, + 'Composite.Group': commonArgTypes, + 'Composite.GroupLabel': commonArgTypes, + 'Composite.Row': commonArgTypes, + 'Composite.Item': { + ...commonArgTypes, + accessibleWhenDisabled, + }, + 'Composite.Hover': commonArgTypes, + 'Composite.Typeahead': commonArgTypes, + }; + + const name = component.displayName ?? ''; + + return name in argTypes + ? argTypes[ name as keyof typeof argTypes ] + : {}; + }, + }, + }, + decorators: [ + ( Story ) => { + return ( + <> + { /* Visually style the active composite item */ } + + +
+ { /* eslint-disable-next-line no-restricted-syntax */ } +

Notes

+
    +
  • + The active composite item is highlighted with a + different background color; +
  • +
  • + A composite item can be the active item even + when it doesn't have keyboard focus. +
  • +
+
+ + ); + }, + ], +}; +export default meta; + +export const Default: StoryFn< typeof UseCompositeStorePlaceholder > = ( + storeProps +) => { + const rtl = isRTL(); + const store = useCompositeStore( { rtl, ...storeProps } ); + + return ( + + Item one + Item two + Item three + + ); +}; + +export const Groups: StoryFn< typeof UseCompositeStorePlaceholder > = ( + storeProps +) => { + const rtl = isRTL(); + const store = useCompositeStore( { rtl, ...storeProps } ); + + return ( + + + Group one + Item 1.1 + Item 1.2 + + + Group two + Item 2.1 + Item 2.1 + + + ); +}; + +export const Grid: StoryFn< typeof UseCompositeStorePlaceholder > = ( + storeProps +) => { + const rtl = isRTL(); + const store = useCompositeStore( { rtl, ...storeProps } ); + + return ( + + + Item A1 + Item A2 + Item A3 + + + Item B1 + Item B2 + Item B3 + + + Item C1 + Item C2 + Item C3 + + + ); +}; + +export const Hover: StoryFn< typeof UseCompositeStorePlaceholder > = ( + storeProps +) => { + const rtl = isRTL(); + const store = useCompositeStore( { rtl, ...storeProps } ); + + return ( + + }> + Hover item one + + }> + Hover item two + + }> + Hover item three + + + ); +}; +Hover.parameters = { + docs: { + description: { + story: 'Elements in the composite widget will receive focus on mouse move and lose focus to the composite base element on mouse leave.', + }, + }, +}; + +export const Typeahead: StoryFn< typeof UseCompositeStorePlaceholder > = ( + storeProps +) => { + const rtl = isRTL(); + const store = useCompositeStore( { rtl, ...storeProps } ); + + return ( + }> + Apple + Banana + Peach + + ); +}; +Typeahead.parameters = { + docs: { + description: { + story: 'When focus in on the composite widget, hitting printable character keys will move focus to the next composite item that begins with the input characters.', + }, + }, +}; diff --git a/packages/components/src/composite/current/stories/utils.tsx b/packages/components/src/composite/stories/utils.tsx similarity index 62% rename from packages/components/src/composite/current/stories/utils.tsx rename to packages/components/src/composite/stories/utils.tsx index 4b2d1bba4b312b..f2f197877ff76d 100644 --- a/packages/components/src/composite/current/stories/utils.tsx +++ b/packages/components/src/composite/stories/utils.tsx @@ -6,8 +6,23 @@ import type { StoryContext } from '@storybook/react'; /** * Internal dependencies */ -import type { CompositeStoreProps } from '..'; +import type { CompositeStoreProps } from '../types'; +/** + * Renders a widget based on the WAI-ARIA [`composite`](https://w3c.github.io/aria/#composite) + * role, which provides a single tab stop on the page and arrow key navigation + * through the focusable descendants. + * + * ```jsx + * import { Composite, useCompositeStore } from '@wordpress/components'; + * + * const store = useCompositeStore(); + * + * Item 1 + * Item 2 + * + * ``` + */ export function UseCompositeStorePlaceholder( props: CompositeStoreProps ) { return (
@@ -22,17 +37,17 @@ export function UseCompositeStorePlaceholder( props: CompositeStoreProps ) { } UseCompositeStorePlaceholder.displayName = 'useCompositeStore'; +// The output generated by Storybook for these components is +// messy, so we apply this transform to make it more useful +// for anyone reading the docs. export function transform( code: string, context: StoryContext ) { - // The output generated by Storybook for these components is - // messy, so we apply this transform to make it more useful - // for anyone reading the docs. - const config = ` ${ JSON.stringify( context.args, null, 2 ) } `; - const state = config.replace( ' {} ', '' ); + const storeConfig = ` ${ JSON.stringify( context.args, null, 2 ) } `; + const formattedStoreConfig = storeConfig.replace( ' {} ', '' ); return [ // Include a setup line, showing how to make use of // `useCompositeStore` to convert store options into // a composite store prop. - `const store = useCompositeStore(${ state });`, + `const store = useCompositeStore(${ formattedStoreConfig });`, '', 'return (', ' ' + diff --git a/packages/components/src/composite/types.ts b/packages/components/src/composite/types.ts new file mode 100644 index 00000000000000..05a2b8473eb349 --- /dev/null +++ b/packages/components/src/composite/types.ts @@ -0,0 +1,298 @@ +/** + * External dependencies + */ +import type * as Ariakit from '@ariakit/react'; + +export type CompositeContextProps = + | { + /** + * Object returned by the `useCompositeStore` hook. + */ + store: Ariakit.CompositeStore; + } + | undefined; + +export type CompositeStoreProps = { + /** + * The current active item `id`. The active item is the element within the + * composite widget that has either DOM or virtual focus (in case + * the `virtualFocus` prop is enabled). + * - `null` represents the base composite element (the one with a [composite + * role](https://w3c.github.io/aria/#composite)). Users will be able to + * navigate out of it using arrow keys. + * - If `activeId` is initially set to `null`, the base composite element + * itself will have focus and users will be able to navigate to it using + * arrow keys. + */ + activeId?: Ariakit.CompositeStoreProps[ 'activeId' ]; + /** + * The composite item id that should be active by default when the composite + * widget is rendered. If `null`, the composite element itself will have focus + * and users will be able to navigate to it using arrow keys. If `undefined`, + * the first enabled item will be focused. + */ + defaultActiveId?: Ariakit.CompositeStoreProps[ 'defaultActiveId' ]; + /** + * A callback that gets called when the `activeId` state changes. + */ + setActiveId?: Ariakit.CompositeStoreProps[ 'setActiveId' ]; + /** + * Determines how the focus behaves when the user reaches the end of the + * composite widget. + * + * On one-dimensional composite widgets: + * - `true` loops from the last item to the first item and vice-versa. + * - `horizontal` loops only if `orientation` is `horizontal` or not set. + * - `vertical` loops only if `orientation` is `vertical` or not set. + * - If `activeId` is initially set to `null`, the composite element will + * be focused in between the last and first items. + * + * On two-dimensional composite widgets (ie. when using `CompositeRow`): + * - `true` loops from the last row/column item to the first item in the same + * row/column and vice-versa. If it's the last item in the last row, it + * moves to the first item in the first row and vice-versa. + * - `horizontal` loops only from the last row item to the first item in the + * same row. + * - `vertical` loops only from the last column item to the first item in the + * column row. + * - If `activeId` is initially set to `null`, vertical loop will have no + * effect as moving down from the last row or up from the first row will + * focus on the composite element. + * - If `focusWrap` matches the value of `focusLoop`, it'll wrap between the + * last item in the last row or column and the first item in the first row or + * column and vice-versa. + * + * @default false + */ + focusLoop?: Ariakit.CompositeStoreProps[ 'focusLoop' ]; + /** + * **Works only on two-dimensional composite widgets**. + * + * If enabled, moving to the next item from the last one in a row or column + * will focus on the first item in the next row or column and vice-versa. + * - `true` wraps between rows and columns. + * - `horizontal` wraps only between rows. + * - `vertical` wraps only between columns. + * - If `focusLoop` matches the value of `focusWrap`, it'll wrap between the + * last item in the last row or column and the first item in the first row or + * column and vice-versa. + * + * @default false + */ + focusWrap?: Ariakit.CompositeStoreProps[ 'focusWrap' ]; + /** + * **Works only on two-dimensional composite widgets**. + * + * If enabled, moving up or down when there's no next item or when the next + * item is disabled will shift to the item right before it. + * + * @default false + */ + focusShift?: Ariakit.CompositeStoreProps[ 'focusShift' ]; + /** + * If enabled, the composite element will act as an + * [`aria-activedescendant`](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant) + * container instead of [roving + * tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex). + * DOM focus will remain on the composite element while its items receive + * virtual focus. + * + * In both scenarios, the item in focus will carry the `data-active-item` + * attribute. + * + * @default false + */ + virtualFocus?: Ariakit.CompositeStoreProps[ 'virtualFocus' ]; + /** + * Defines the orientation of the composite widget. If the composite has a + * single row or column (one-dimensional), the `orientation` value determines + * which arrow keys can be used to move focus: + * - `both`: all arrow keys work. + * - `horizontal`: only left and right arrow keys work. + * - `vertical`: only up and down arrow keys work. + * + * It doesn't have any effect on two-dimensional composites. + * + * @default "both" + */ + orientation?: Ariakit.CompositeStoreProps[ 'orientation' ]; + /** + * Determines how the `store`'s `next` and `previous` functions will behave. + * If `rtl` is set to `true`, they will be inverted. + * + * This only affects the composite widget behavior. You still need to set + * `dir="rtl"` on HTML/CSS. + * + * @default false + */ + rtl?: Ariakit.CompositeStoreProps[ 'rtl' ]; +}; + +export type CompositeProps = { + /** + * Object returned by the `useCompositeStore` hook. + */ + store: Ariakit.CompositeStore; + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeProps[ 'render' ]; + /** + * Makes the component a focusable element. When this element gains keyboard + * focus, it gets a `data-focus-visible` attribute and triggers the + * `onFocusVisible` prop. + * The component supports the `disabled` prop even for those elements not + * supporting the native `disabled` attribute. Disabled elements may be + * still accessible via keyboard by using the the `accessibleWhenDisabled` + * prop. + * Non-native focusable elements will lose their focusability entirely. + * However, native focusable elements will retain their inherent focusability. + */ + focusable?: Ariakit.CompositeProps[ 'focusable' ]; + /** + * Determines if the element is disabled. This sets the `aria-disabled` + * attribute accordingly, enabling support for all elements, including those + * that don't support the native `disabled` attribute. + * + * This feature can be combined with the `accessibleWhenDisabled` prop to + * make disabled elements still accessible via keyboard. + * + * **Note**: For this prop to work, the `focusable` prop must be set to + * `true`, if it's not set by default. + * + * @default false + */ + disabled?: Ariakit.CompositeProps[ 'disabled' ]; + /** + * Indicates whether the element should be focusable even when it is + * `disabled`. + * + * This is important when discoverability is a concern. For example: + * + * > A toolbar in an editor contains a set of special smart paste functions + * that are disabled when the clipboard is empty or when the function is not + * applicable to the current content of the clipboard. It could be helpful to + * keep the disabled buttons focusable if the ability to discover their + * functionality is primarily via their presence on the toolbar. + * + * Learn more on [Focusability of disabled + * controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols). + */ + accessibleWhenDisabled?: Ariakit.CompositeProps[ 'accessibleWhenDisabled' ]; + /** + * Custom event handler invoked when the element gains focus through keyboard + * interaction or a key press occurs while the element is in focus. This is + * the programmatic equivalent of the `data-focus-visible` attribute. + * + * **Note**: For this prop to work, the `focusable` prop must be set to `true` + * if it's not set by default. + */ + onFocusVisible?: Ariakit.CompositeProps[ 'onFocusVisible' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeProps[ 'children' ]; +}; + +export type CompositeGroupProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeGroupProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeGroupProps[ 'children' ]; +}; + +export type CompositeGroupLabelProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeGroupLabelProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeGroupLabelProps[ 'children' ]; +}; + +export type CompositeItemProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeItemProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeItemProps[ 'children' ]; + /** + * Indicates whether the element should be focusable even when it is + * `disabled`. + * + * This is important when discoverability is a concern. For example: + * + * > A toolbar in an editor contains a set of special smart paste functions + * that are disabled when the clipboard is empty or when the function is not + * applicable to the current content of the clipboard. It could be helpful to + * keep the disabled buttons focusable if the ability to discover their + * functionality is primarily via their presence on the toolbar. + * + * Learn more on [Focusability of disabled + * controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols). + */ + accessibleWhenDisabled?: Ariakit.CompositeItemProps[ 'accessibleWhenDisabled' ]; +}; + +export type CompositeRowProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeRowProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeRowProps[ 'children' ]; +}; + +export type CompositeHoverProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeHoverProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeHoverProps[ 'children' ]; +}; + +export type CompositeTypeaheadProps = { + /** + * Allows the component to be rendered as a different HTML element or React + * component. The value can be a React element or a function that takes in the + * original component props and gives back a React element with the props + * merged. + */ + render?: Ariakit.CompositeTypeaheadProps[ 'render' ]; + /** + * The contents of the component. + */ + children?: Ariakit.CompositeTypeaheadProps[ 'children' ]; +}; diff --git a/packages/components/src/composite/v2.ts b/packages/components/src/composite/v2.ts deleted file mode 100644 index 38d3f628d368b6..00000000000000 --- a/packages/components/src/composite/v2.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Although we have migrated away from Reakit, the 'current' -// Ariakit implementation is still considered a v2. - -export * from './current'; diff --git a/packages/components/src/custom-gradient-picker/style.scss b/packages/components/src/custom-gradient-picker/style.scss index e7828127ff8d33..a8c6d6b872caf0 100644 --- a/packages/components/src/custom-gradient-picker/style.scss +++ b/packages/components/src/custom-gradient-picker/style.scss @@ -1,7 +1,7 @@ $components-custom-gradient-picker__padding: $grid-unit-20; // 48px container, 16px handles inside, that leaves 32px padding, half of which is 1å6. .components-custom-gradient-picker__gradient-bar { - border-radius: $radius-block-ui; + border-radius: $radius-small; width: 100%; height: $grid-unit-60; position: relative; diff --git a/packages/components/src/custom-select-control/test/index.tsx b/packages/components/src/custom-select-control/test/index.tsx index fdbe8d72a48dec..b2ac5c19c6ab3f 100644 --- a/packages/components/src/custom-select-control/test/index.tsx +++ b/packages/components/src/custom-select-control/test/index.tsx @@ -689,3 +689,128 @@ describe.each( [ } ); } ); } ); + +describe( 'Type checking', () => { + // eslint-disable-next-line jest/expect-expect + it( 'should infer the value type from available `options`, but not the `value` or `onChange` prop', () => { + const options = [ + { + key: 'narrow', + name: 'Narrow', + }, + { + key: 'value', + name: 'Value', + }, + ]; + const optionsReadOnly = [ + { + key: 'narrow', + name: 'Narrow', + }, + { + key: 'value', + name: 'Value', + }, + ] as const; + + const onChange = (): void => {}; + + ; + + ; + + ; + + void + } + />; + + ; + + ; + + ; + + void + } + />; + } ); +} ); diff --git a/packages/components/src/custom-select-control/types.ts b/packages/components/src/custom-select-control/types.ts index e37ba349a2b843..0cbc2388e79638 100644 --- a/packages/components/src/custom-select-control/types.ts +++ b/packages/components/src/custom-select-control/types.ts @@ -55,7 +55,7 @@ export type CustomSelectProps< T extends CustomSelectOption > = { * Function called with the control's internal state changes. The `selectedItem` * property contains the next selected item. */ - onChange?: ( newValue: CustomSelectChangeObject< T > ) => void; + onChange?: ( newValue: CustomSelectChangeObject< NoInfer< T > > ) => void; /** * A handler for `blur` events on the trigger button. * @@ -83,7 +83,7 @@ export type CustomSelectProps< T extends CustomSelectOption > = { /** * The list of options that can be chosen from. */ - options: Array< T >; + options: ReadonlyArray< T >; /** * The size of the control. * @@ -93,7 +93,7 @@ export type CustomSelectProps< T extends CustomSelectOption > = { /** * Can be used to externally control the value of the control. */ - value?: T; + value?: NoInfer< T >; /** * Use the `showSelectedHint` property instead. * @deprecated diff --git a/packages/components/src/date-time/time/index.tsx b/packages/components/src/date-time/time/index.tsx index 5f706d69190095..90a8a901354a39 100644 --- a/packages/components/src/date-time/time/index.tsx +++ b/packages/components/src/date-time/time/index.tsx @@ -13,6 +13,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import BaseControl from '../../base-control'; +import { VisuallyHidden } from '../../visually-hidden'; import SelectControl from '../../select-control'; import TimeZone from './timezone'; import type { TimeInputValue, TimePickerProps } from '../types'; @@ -61,6 +62,7 @@ export function TimePicker( { currentTime, onChange, dateOrder: dateOrderProp, + hideLabelFromVision = false, }: TimePickerProps ) { const [ date, setDate ] = useState( () => // Truncate the date at the minutes, see: #15495. @@ -219,12 +221,18 @@ export function TimePicker( { className="components-datetime__time" // Unused, for backwards compatibility. >
- - { __( 'Time' ) } - + { hideLabelFromVision ? ( + + { __( 'Time' ) } + + ) : ( + + { __( 'Time' ) } + + ) } @@ -241,12 +249,18 @@ export function TimePicker( {
- - { __( 'Date' ) } - + { hideLabelFromVision ? ( + + { __( 'Date' ) } + + ) : ( + + { __( 'Date' ) } + + ) } diff --git a/packages/components/src/date-time/types.ts b/packages/components/src/date-time/types.ts index b3716d5c135c64..1f2298d9d450b5 100644 --- a/packages/components/src/date-time/types.ts +++ b/packages/components/src/date-time/types.ts @@ -29,6 +29,13 @@ export type TimePickerProps = { * time as an argument. */ onChange?: ( time: string ) => void; + + /** + * If true, the label will only be visible to screen readers. + * + * @default false + */ + hideLabelFromVision?: boolean; }; export type TimeInputValue = { @@ -130,7 +137,10 @@ export type DatePickerProps = { }; export type DateTimePickerProps = Omit< DatePickerProps, 'onChange' > & - Omit< TimePickerProps, 'currentTime' | 'onChange' > & { + Omit< + TimePickerProps, + 'currentTime' | 'onChange' | 'hideLabelFromVision' + > & { /** * The function called when a new date or time has been selected. It is * passed the date and time as an argument. diff --git a/packages/components/src/dimension-control/README.md b/packages/components/src/dimension-control/README.md index 498322931b7ab6..42f4a9678c09a6 100644 --- a/packages/components/src/dimension-control/README.md +++ b/packages/components/src/dimension-control/README.md @@ -17,6 +17,7 @@ export default function MyCustomDimensionControl() { return ( setPaddingSize( value ) } @@ -91,3 +92,11 @@ A callback which is triggered when a spacing size value changes (is selected/cli - **Required:** No A string of classes to be added to the control component. + +### __nextHasNoMarginBottom + +Start opting into the new margin-free styles that will become the default in a future version. + +- Type: `Boolean` +- Required: No +- Default: `false` diff --git a/packages/components/src/dimension-control/index.tsx b/packages/components/src/dimension-control/index.tsx index 8ba0de0ce0599c..52662f31c3f24c 100644 --- a/packages/components/src/dimension-control/index.tsx +++ b/packages/components/src/dimension-control/index.tsx @@ -16,6 +16,15 @@ import SelectControl from '../select-control'; import sizesTable, { findSizeBySlug } from './sizes'; import type { DimensionControlProps, Size } from './types'; import type { SelectControlSingleSelectionProps } from '../select-control/types'; +import { ContextSystemProvider } from '../context'; + +const CONTEXT_VALUE = { + BaseControl: { + // Temporary during deprecation grace period: Overrides the underlying `__associatedWPComponentName` + // via the context system to override the value set by SelectControl. + _overrides: { __associatedWPComponentName: 'DimensionControl' }, + }, +}; /** * `DimensionControl` is a component designed to provide a UI to control spacing and/or dimensions. @@ -31,6 +40,7 @@ import type { SelectControlSingleSelectionProps } from '../select-control/types' * * return ( * setPaddingSize( value ) } @@ -43,6 +53,7 @@ import type { SelectControlSingleSelectionProps } from '../select-control/types' export function DimensionControl( props: DimensionControlProps ) { const { __next40pxDefaultSize = false, + __nextHasNoMarginBottom = false, label, value, sizes = sizesTable, @@ -85,15 +96,21 @@ export function DimensionControl( props: DimensionControlProps ) { ); return ( - + + + ); } diff --git a/packages/components/src/dimension-control/stories/index.story.tsx b/packages/components/src/dimension-control/stories/index.story.tsx index 33d5bad4ff4b19..3a6da44f461164 100644 --- a/packages/components/src/dimension-control/stories/index.story.tsx +++ b/packages/components/src/dimension-control/stories/index.story.tsx @@ -44,6 +44,7 @@ const Template: StoryFn< typeof DimensionControl > = ( args ) => ( export const Default = Template.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, label: 'Please select a size', sizes, }; diff --git a/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap index 5990fbbd4a3f5f..658fe7febc02bc 100644 --- a/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap +++ b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap @@ -13,10 +13,6 @@ exports[`DimensionControl rendering renders with custom sizes 1`] = ` box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -299,10 +295,6 @@ exports[`DimensionControl rendering renders with defaults 1`] = ` box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -595,10 +587,6 @@ exports[`DimensionControl rendering renders with icon and custom icon label 1`] box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -903,10 +891,6 @@ exports[`DimensionControl rendering renders with icon and default icon label 1`] box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } diff --git a/packages/components/src/dimension-control/test/index.test.js b/packages/components/src/dimension-control/test/index.test.js index 1d65dd86c7e7c8..1b34d2983ad0f1 100644 --- a/packages/components/src/dimension-control/test/index.test.js +++ b/packages/components/src/dimension-control/test/index.test.js @@ -12,7 +12,11 @@ import { plus } from '@wordpress/icons'; /** * Internal dependencies */ -import { DimensionControl } from '../'; +import { DimensionControl as _DimensionControl } from '../'; + +const DimensionControl = ( props ) => { + return <_DimensionControl { ...props } __nextHasNoMarginBottom />; +}; describe( 'DimensionControl', () => { const onChangeHandler = jest.fn(); diff --git a/packages/components/src/dimension-control/types.ts b/packages/components/src/dimension-control/types.ts index 671454f18c8a9b..03a4edf29153b7 100644 --- a/packages/components/src/dimension-control/types.ts +++ b/packages/components/src/dimension-control/types.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import type { IconType } from '../icon'; +import type { SelectControlProps } from '../select-control/types'; export type Size = { /** @@ -14,7 +15,10 @@ export type Size = { slug: string; }; -export type DimensionControlProps = { +export type DimensionControlProps = Pick< + SelectControlProps, + '__next40pxDefaultSize' | '__nextHasNoMarginBottom' +> & { /** * Label for the control. */ @@ -45,10 +49,4 @@ export type DimensionControlProps = { * @default '' */ className?: string; - /** - * Start opting into the larger default height that will become the default size in a future version. - * - * @default false - */ - __next40pxDefaultSize?: boolean; }; diff --git a/packages/components/src/drop-zone/style.scss b/packages/components/src/drop-zone/style.scss index 2793dc708cf03f..d3cd18a75b1f8f 100644 --- a/packages/components/src/drop-zone/style.scss +++ b/packages/components/src/drop-zone/style.scss @@ -7,7 +7,7 @@ z-index: z-index(".components-drop-zone"); visibility: hidden; opacity: 0; - border-radius: $radius-block-ui; + border-radius: $radius-small; &.is-active { opacity: 1; diff --git a/packages/components/src/dropdown-menu-v2/styles.ts b/packages/components/src/dropdown-menu-v2/styles.ts index ab7c763b5f4569..3260a83f45f1c1 100644 --- a/packages/components/src/dropdown-menu-v2/styles.ts +++ b/packages/components/src/dropdown-menu-v2/styles.ts @@ -31,7 +31,7 @@ const ITEM_PADDING_INLINE = space( 3 ); const DEFAULT_BORDER_COLOR = COLORS.gray[ 300 ]; const DIVIDER_COLOR = COLORS.gray[ 200 ]; const TOOLBAR_VARIANT_BORDER_COLOR = COLORS.gray[ '900' ]; -const DEFAULT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ DEFAULT_BORDER_COLOR }, ${ CONFIG.popoverShadow }`; +const DEFAULT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ DEFAULT_BORDER_COLOR }, ${ CONFIG.elevationXSmall }`; const TOOLBAR_VARIANT_BOX_SHADOW = `0 0 0 ${ CONFIG.borderWidth } ${ TOOLBAR_VARIANT_BORDER_COLOR }`; const GRID_TEMPLATE_COLS = 'minmax( 0, max-content ) 1fr'; @@ -87,7 +87,7 @@ export const DropdownMenu = styled( Ariakit.Menu )< padding: ${ CONTENT_WRAPPER_PADDING }; background-color: ${ COLORS.ui.background }; - border-radius: 4px; + border-radius: ${ CONFIG.radiusMedium }; ${ ( props ) => css` box-shadow: ${ props.variant === 'toolbar' ? TOOLBAR_VARIANT_BOX_SHADOW @@ -150,7 +150,7 @@ const baseItem = css` line-height: 20px; color: ${ COLORS.gray[ 900 ] }; - border-radius: ${ CONFIG.radiusBlockUi }; + border-radius: ${ CONFIG.radiusSmall }; padding-block: ${ ITEM_PADDING_BLOCK }; padding-inline: ${ ITEM_PADDING_INLINE }; diff --git a/packages/components/src/focal-point-picker/controls.tsx b/packages/components/src/focal-point-picker/controls.tsx index 3df1725bf3bedb..1ecf846b10635e 100644 --- a/packages/components/src/focal-point-picker/controls.tsx +++ b/packages/components/src/focal-point-picker/controls.tsx @@ -23,7 +23,6 @@ const noop = () => {}; export default function FocalPointPickerControls( { __nextHasNoMarginBottom, - __next40pxDefaultSize, hasHelpText, onChange = noop, point = { @@ -57,7 +56,6 @@ export default function FocalPointPickerControls( { gap={ 4 } > { diff --git a/packages/components/src/focal-point-picker/stories/index.story.tsx b/packages/components/src/focal-point-picker/stories/index.story.tsx index ce93d3557060c9..42fd64b8e4d855 100644 --- a/packages/components/src/focal-point-picker/stories/index.story.tsx +++ b/packages/components/src/focal-point-picker/stories/index.story.tsx @@ -49,6 +49,9 @@ const Template: StoryFn< typeof FocalPointPicker > = ( { }; export const Default = Template.bind( {} ); +Default.args = { + __nextHasNoMarginBottom: true, +}; export const Image = Template.bind( {} ); Image.args = { diff --git a/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts b/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts index 71bf4b651b4ad7..fba3eda2fb0f29 100644 --- a/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts +++ b/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts @@ -22,7 +22,7 @@ export const MediaWrapper = styled.div` export const MediaContainer = styled.div` align-items: center; - border-radius: ${ CONFIG.radiusBlockUi }; + border-radius: ${ CONFIG.radiusSmall }; cursor: pointer; display: inline-flex; justify-content: center; diff --git a/packages/components/src/focal-point-picker/test/index.tsx b/packages/components/src/focal-point-picker/test/index.tsx index 1eccced32c70af..377ba6c4e9e6b3 100644 --- a/packages/components/src/focal-point-picker/test/index.tsx +++ b/packages/components/src/focal-point-picker/test/index.tsx @@ -7,12 +7,16 @@ import userEvent from '@testing-library/user-event'; /** * Internal dependencies */ -import Picker from '..'; +import _Picker from '..'; import type { FocalPointPickerProps } from '../types'; type Log = { name: string; args: unknown[] }; type EventLogger = ( name: string, args: unknown[] ) => void; +const Picker = ( props: React.ComponentProps< typeof _Picker > ) => { + return <_Picker { ...props } __nextHasNoMarginBottom />; +}; + const props: FocalPointPickerProps = { onChange: jest.fn(), url: 'test-url', diff --git a/packages/components/src/focal-point-picker/types.ts b/packages/components/src/focal-point-picker/types.ts index bd66ae02451a95..357af1e090d7f5 100644 --- a/packages/components/src/focal-point-picker/types.ts +++ b/packages/components/src/focal-point-picker/types.ts @@ -29,7 +29,8 @@ export type FocalPointPickerProps = Pick< /** * Start opting into the larger default height that will become the default size in a future version. * - * @default false + * @deprecated Default behavior since WP 6.7. Prop can be safely removed. + * @ignore */ __next40pxDefaultSize?: boolean; /** @@ -68,7 +69,6 @@ export type FocalPointPickerProps = Pick< export type FocalPointPickerControlsProps = { __nextHasNoMarginBottom?: boolean; - __next40pxDefaultSize?: boolean; /** * A bit of extra bottom margin will be added if a `help` text * needs to be rendered under it. diff --git a/packages/components/src/guide/style.scss b/packages/components/src/guide/style.scss index 76f7b03ac4aafc..073bfc06843075 100644 --- a/packages/components/src/guide/style.scss +++ b/packages/components/src/guide/style.scss @@ -9,7 +9,6 @@ .components-modal__content { padding: 0; margin-top: 0; - border-radius: $radius-block-ui; &::before { content: none; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index f0ea4a4b7e86b8..6483e34dc222a8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -61,7 +61,7 @@ export { CompositeGroup as __unstableCompositeGroup, CompositeItem as __unstableCompositeItem, useCompositeState as __unstableUseCompositeState, -} from './composite'; +} from './composite/legacy'; export { ConfirmDialog as __experimentalConfirmDialog } from './confirm-dialog'; export { default as CustomSelectControl } from './custom-select-control'; export { default as Dashicon } from './dashicon'; diff --git a/packages/components/src/item-group/styles.ts b/packages/components/src/item-group/styles.ts index 166f225790ce27..66c6158f9af1a3 100644 --- a/packages/components/src/item-group/styles.ts +++ b/packages/components/src/item-group/styles.ts @@ -70,7 +70,7 @@ export const separated = css` } `; -const borderRadius = CONFIG.controlBorderRadius; +const borderRadius = CONFIG.radiusSmall; export const spacedAround = css` border-radius: ${ borderRadius }; diff --git a/packages/components/src/mobile/utils/alignments.native.js b/packages/components/src/mobile/utils/alignments.native.js index bc42385988a5d1..f1f737d7ed367a 100644 --- a/packages/components/src/mobile/utils/alignments.native.js +++ b/packages/components/src/mobile/utils/alignments.native.js @@ -13,6 +13,7 @@ export const WIDE_ALIGNMENTS = { 'core/image', 'core/separator', 'core/media-text', + 'core/quote', 'core/pullquote', ], }; diff --git a/packages/components/src/modal/style.scss b/packages/components/src/modal/style.scss index e563b29070bef2..c862363a0d3c81 100644 --- a/packages/components/src/modal/style.scss +++ b/packages/components/src/modal/style.scss @@ -21,7 +21,7 @@ width: 100%; background: $white; box-shadow: $shadow-modal; - border-radius: $grid-unit-05 $grid-unit-05 0 0; + border-radius: $radius-large $radius-large 0 0; overflow: hidden; // Have the content element fill the vertical space yet not overflow. display: flex; @@ -32,7 +32,7 @@ // Show a centered modal on bigger screens. @include break-small() { - border-radius: $grid-unit-05; + border-radius: $radius-large; margin: auto; width: auto; min-width: $modal-min-width; diff --git a/packages/components/src/navigator/navigator-back-button/component.tsx b/packages/components/src/navigator/navigator-back-button/component.tsx index 71c5ac14cd00d9..88ed45b643a13d 100644 --- a/packages/components/src/navigator/navigator-back-button/component.tsx +++ b/packages/components/src/navigator/navigator-back-button/component.tsx @@ -48,7 +48,7 @@ function UnconnectedNavigatorBackButton( * *

This is the child screen.

* - * Go back + * Go back (to parent) * *
* diff --git a/packages/components/src/navigator/navigator-back-button/hook.ts b/packages/components/src/navigator/navigator-back-button/hook.ts index edf55be0f15f5b..d4447b5f40ad46 100644 --- a/packages/components/src/navigator/navigator-back-button/hook.ts +++ b/packages/components/src/navigator/navigator-back-button/hook.ts @@ -10,31 +10,27 @@ import type { WordPressComponentProps } from '../../context'; import { useContextSystem } from '../../context'; import Button from '../../button'; import useNavigator from '../use-navigator'; -import type { NavigatorBackButtonHookProps } from '../types'; +import type { NavigatorBackButtonProps } from '../types'; export function useNavigatorBackButton( - props: WordPressComponentProps< NavigatorBackButtonHookProps, 'button' > + props: WordPressComponentProps< NavigatorBackButtonProps, 'button' > ) { const { onClick, as = Button, - goToParent: goToParentProp = false, + ...otherProps } = useContextSystem( props, 'NavigatorBackButton' ); - const { goBack, goToParent } = useNavigator(); + const { goBack } = useNavigator(); const handleClick: React.MouseEventHandler< HTMLButtonElement > = useCallback( ( e ) => { e.preventDefault(); - if ( goToParentProp ) { - goToParent(); - } else { - goBack(); - } + goBack(); onClick?.( e ); }, - [ goToParentProp, goToParent, goBack, onClick ] + [ goBack, onClick ] ); return { diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md index 8be27a65101843..13745fae68a15d 100644 --- a/packages/components/src/navigator/navigator-provider/README.md +++ b/packages/components/src/navigator/navigator-provider/README.md @@ -10,38 +10,42 @@ The `NavigatorProvider` component allows rendering nested views/panels/menus (vi ```jsx import { - __experimentalNavigatorProvider as NavigatorProvider, - __experimentalNavigatorScreen as NavigatorScreen, - __experimentalNavigatorButton as NavigatorButton, - __experimentalNavigatorToParentButton as NavigatorToParentButton, + __experimentalNavigatorProvider as NavigatorProvider, + __experimentalNavigatorScreen as NavigatorScreen, + __experimentalNavigatorButton as NavigatorButton, + __experimentalNavigatorBackButton as NavigatorBackButton, } from '@wordpress/components'; const MyNavigation = () => ( - - -

This is the home screen.

- - Navigate to child screen. - -
- - -

This is the child screen.

- - Go back - -
-
+ + +

This is the home screen.

+ + Navigate to child screen. + +
+ + +

This is the child screen.

+ Go back +
+
); ``` + **Important note** -Parent/child navigation only works if the path you define are hierarchical, following a URL-like scheme where each path segment is separated by the `/` character. +`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. + +`Navigator` will treat "back" navigations as going to the parent screen — it is therefore responsibility of the consumer of the component to create the correct screen hierarchy. + For example: -- `/` is the root of all paths. There should always be a screen with `path="/"`. -- `/parent/child` is a child of `/parent`. -- `/parent/child/grand-child` is a child of `/parent/child`. -- `/parent/:param` is a child of `/parent` as well. + +- `/` is the root of all paths. There should always be a screen with `path="/"`. +- `/parent/child` is a child of `/parent`. +- `/parent/child/grand-child` is a child of `/parent/child`. +- `/parent/:param` is a child of `/parent` as well. +- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. ## Props @@ -65,28 +69,26 @@ The `goTo` function allows navigating to a given path. The second argument can a The available options are: -- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back. -- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too) - -### `goToParent`: `() => void;` +- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back; +- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too); +- `skipFocus`: `boolean`. An optional property used to opt out of `Navigator`'s focus management, useful when the consumer of the component wants to manage focus themselves; +- `replace`: `boolean`. An optional property used to cause the new location to replace the current location in the stack. -The `goToParent` function allows navigating to the parent screen. +### `goBack`: `( path: string, options: NavigateOptions ) => void` -Parent/child navigation only works if the path you define are hierarchical (see note above). +The `goBack` function allows navigating to the parent screen. Parent/child navigation only works if the paths you define are hierarchical (see note above). When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) are found. -### `goBack`: `() => void` - -The `goBack` function allows navigating to the previous path. +The available options are the same as for the `goTo` method, except for the `isBack` property, which is not available for the `goBack` method. ### `location`: `NavigatorLocation` The `location` object represent the current location, and has a few properties: -- `path`: `string`. The path associated to the location. -- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards in the location stack. -- `isInitial`: `boolean`. A flag that is `true` only for the first (root) location in the location stack. +- `path`: `string`. The path associated to the location. +- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards in the location history. +- `isInitial`: `boolean`. A flag that is `true` only for the first (root) location in the location history. ### `params`: `Record< string, string | string[] >` diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx index 15eb4d6bd3b1d3..78bc674c06fbd5 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator-provider/component.tsx @@ -27,12 +27,12 @@ import type { Screen, NavigateToParentOptions, } from '../types'; +import deprecated from '@wordpress/deprecated'; type MatchedPath = ReturnType< typeof patternMatch >; type RouterAction = | { type: 'add' | 'remove'; screen: Screen } - | { type: 'goback' } | { type: 'goto'; path: string; options?: NavigateOptions } | { type: 'gotoparent'; options?: NavigateToParentOptions }; @@ -160,9 +160,6 @@ function routerReducer( case 'remove': screens = removeScreen( state, action.screen ); break; - case 'goback': - locationHistory = goBack( state ); - break; case 'goto': locationHistory = goTo( state, action.path, action.options ); break; @@ -223,11 +220,20 @@ function UnconnectedNavigatorProvider( // The methods are constant forever, create stable references to them. const methods = useMemo( () => ( { - goBack: () => dispatch( { type: 'goback' } ), + // Note: calling goBack calls `goToParent` internally, as it was established + // that `goBack` should behave like `goToParent`, and `goToParent` should + // be marked as deprecated. + goBack: ( options: NavigateToParentOptions | undefined ) => + dispatch( { type: 'gotoparent', options } ), goTo: ( path: string, options?: NavigateOptions ) => dispatch( { type: 'goto', path, options } ), - goToParent: ( options: NavigateToParentOptions | undefined ) => - dispatch( { type: 'gotoparent', options } ), + goToParent: ( options: NavigateToParentOptions | undefined ) => { + deprecated( `wp.components.useNavigator().goToParent`, { + since: '6.7', + alternative: 'wp.components.useNavigator().goBack', + } ); + dispatch( { type: 'gotoparent', options } ); + }, addScreen: ( screen: Screen ) => dispatch( { type: 'add', screen } ), removeScreen: ( screen: Screen ) => diff --git a/packages/components/src/navigator/navigator-screen/README.md b/packages/components/src/navigator/navigator-screen/README.md index 5ba5af44fe8c1a..583da461cd3c27 100644 --- a/packages/components/src/navigator/navigator-screen/README.md +++ b/packages/components/src/navigator/navigator-screen/README.md @@ -16,6 +16,18 @@ The component accepts the following props: ### `path`: `string` -The screen's path, matched against the current path stored in the navigator. +The screen"s path, matched against the current path stored in the navigator. + +`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. + +`Navigator` will treat "back" navigations as going to the parent screen — it is therefore responsibility of the consumer of the component to create the correct screen hierarchy. + +For example: + +- `/` is the root of all paths. There should always be a screen with `path="/"`. +- `/parent/child` is a child of `/parent`. +- `/parent/child/grand-child` is a child of `/parent/child`. +- `/parent/:param` is a child of `/parent` as well. +- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. - Required: Yes diff --git a/packages/components/src/navigator/navigator-to-parent-button/README.md b/packages/components/src/navigator/navigator-to-parent-button/README.md index 62dacc3dfa4ea5..0100ea9b8d2e1f 100644 --- a/packages/components/src/navigator/navigator-to-parent-button/README.md +++ b/packages/components/src/navigator/navigator-to-parent-button/README.md @@ -4,6 +4,8 @@ This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +This component is deprecated. Please use the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) component instead. + The `NavigatorToParentButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook). ## Usage diff --git a/packages/components/src/navigator/navigator-to-parent-button/component.tsx b/packages/components/src/navigator/navigator-to-parent-button/component.tsx index e73a3619f3d494..400498b1fc96ca 100644 --- a/packages/components/src/navigator/navigator-to-parent-button/component.tsx +++ b/packages/components/src/navigator/navigator-to-parent-button/component.tsx @@ -1,62 +1,33 @@ /** - * External dependencies + * WordPress dependencies */ -import type { ForwardedRef } from 'react'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ +import { NavigatorBackButton } from '../navigator-back-button'; import type { WordPressComponentProps } from '../../context'; import { contextConnect } from '../../context'; -import { View } from '../../view'; -import { useNavigatorBackButton } from '../navigator-back-button/hook'; -import type { NavigatorToParentButtonProps } from '../types'; +import type { NavigatorBackButtonProps } from '../types'; function UnconnectedNavigatorToParentButton( - props: WordPressComponentProps< NavigatorToParentButtonProps, 'button' >, - forwardedRef: ForwardedRef< any > + props: WordPressComponentProps< NavigatorBackButtonProps, 'button' >, + forwardedRef: React.ForwardedRef< any > ) { - const navigatorToParentButtonProps = useNavigatorBackButton( { - ...props, - goToParent: true, + deprecated( 'wp.components.NavigatorToParentButton', { + since: '6.7', + alternative: 'wp.components.NavigatorBackButton', } ); - return ; + return ; } -/* - * The `NavigatorToParentButton` component can be used to navigate to a screen and - * should be used in combination with the `NavigatorProvider`, the - * `NavigatorScreen` and the `NavigatorButton` components (or the `useNavigator` - * hook). - * - * @example - * ```jsx - * import { - * __experimentalNavigatorProvider as NavigatorProvider, - * __experimentalNavigatorScreen as NavigatorScreen, - * __experimentalNavigatorButton as NavigatorButton, - * __experimentalNavigatorToParentButton as NavigatorToParentButton, - * } from '@wordpress/components'; - * - * const MyNavigation = () => ( - * - * - *

This is the home screen.

- * - * Navigate to child screen. - * - *
+/** + * _Note: this component is deprecated. Please use the `NavigatorBackButton` + * component instead._ * - * - *

This is the child screen.

- * - * Go to parent - * - *
- *
- * ); - * ``` + * @deprecated */ export const NavigatorToParentButton = contextConnect( UnconnectedNavigatorToParentButton, diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx index b83bd70d9d7444..9b9b257ea09681 100644 --- a/packages/components/src/navigator/test/index.tsx +++ b/packages/components/src/navigator/test/index.tsx @@ -25,8 +25,8 @@ import { import type { NavigateOptions } from '../types'; const INVALID_HTML_ATTRIBUTE = { - raw: ' "\'><=invalid_path', - escaped: " "'><=invalid_path", + raw: '/ "\'><=invalid_path', + escaped: "/ "'><=invalid_path", }; const PATHS = { @@ -165,6 +165,27 @@ function CustomNavigatorToParentButton( { ); } +function CustomNavigatorToParentButtonAlternative( { + onClick, + children, +}: { + children: React.ReactNode; + onClick?: CustomTestOnClickHandler; +} ) { + const { goToParent } = useNavigator(); + return ( + + ); +} + const ProductScreen = ( { onBackButtonClick, }: { @@ -344,20 +365,20 @@ const MyHierarchicalNavigation = ( { > { BUTTON_TEXT.toNestedScreen } - { BUTTON_TEXT.back } - +

{ SCREEN_TEXT.nested }

- { BUTTON_TEXT.back } - + { + return ( + <> + + +

{ SCREEN_TEXT.home }

+ { /* + * A button useful to test focus restoration. This button is the first + * tabbable item in the screen, but should not receive focus when + * navigating to screen as a result of a backwards navigation. + */ } + + + { BUTTON_TEXT.toChildScreen } + +
+ + +

{ SCREEN_TEXT.child }

+ { /* + * A button useful to test focus restoration. This button is the first + * tabbable item in the screen, but should not receive focus when + * navigating to screen as a result of a backwards navigation. + */ } + + + { BUTTON_TEXT.toNestedScreen } + + + { BUTTON_TEXT.back } + +
+ + +

{ SCREEN_TEXT.nested }

+ + { BUTTON_TEXT.back } + +
+
+ + ); +}; + const getScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => screen.getByText( SCREEN_TEXT[ screenKey ] ); const queryScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => @@ -769,4 +850,53 @@ describe( 'Navigator', () => { ).toHaveFocus(); } ); } ); + + describe( 'deprecated APIs', () => { + it( 'should log a deprecation notice when using the NavigatorToParentButton component', async () => { + const user = userEvent.setup(); + + render( ); + + expect( getScreen( 'child' ) ).toBeInTheDocument(); + + // Navigate back to home screen. + // The first tabbable element receives focus, since focus restoration + // it not possible (there was no forward navigation). + await user.click( getNavigationButton( 'back' ) ); + expect( getScreen( 'home' ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { + name: 'First tabbable home screen button', + } ) + ).toHaveFocus(); + + // Rendering `NavigatorToParentButton` logs a deprecation notice + expect( console ).toHaveWarnedWith( + 'wp.components.NavigatorToParentButton is deprecated since version 6.7. Please use wp.components.NavigatorBackButton instead.' + ); + } ); + + it( 'should log a deprecation notice when using the useNavigator().goToParent() function', async () => { + const user = userEvent.setup(); + + render( ); + + expect( getScreen( 'nested' ) ).toBeInTheDocument(); + + // Navigate back to child screen using the back button. + // The first tabbable element receives focus, since focus restoration + // it not possible (there was no forward navigation). + await user.click( getNavigationButton( 'back' ) ); + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( + screen.getByRole( 'button', { + name: 'First tabbable child screen button', + } ) + ).toHaveFocus(); + + expect( console ).toHaveWarnedWith( + 'wp.components.useNavigator().goToParent is deprecated since version 6.7. Please use wp.components.useNavigator().goBack instead.' + ); + } ); + } ); } ); diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index 557f8074fd42e2..c45762d558af2d 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -11,26 +11,70 @@ import type { ButtonAsButtonProps } from '../button/types'; export type MatchParams = Record< string, string | string[] >; export type NavigateOptions = { + /** + * Specify the CSS selector used to restore focus on an given element when + * navigating back. When not provided, the component will attempt to restore + * focus on the element that originated the forward navigation. + */ focusTargetSelector?: string; + /** + * Whether the navigation is a backwards navigation. This enables focus + * restoration (when possible), and causes the animation to be backwards. + */ isBack?: boolean; + /** + * Opt out of focus management. Useful when the consumer of the component + * wants to manage focus themselves. + */ skipFocus?: boolean; + /** + * Whether the navigation should replace the current location in the stack. + */ replace?: boolean; }; export type NavigateToParentOptions = Omit< NavigateOptions, 'isBack' >; export type NavigatorLocation = NavigateOptions & { + /** + * Whether the current location is the initial one (ie. first in the stack). + */ isInitial?: boolean; + /** + * The path associated to the location. + */ path?: string; + /** + * Whether focus was already restored for this location (in case of + * backwards navigation). + */ hasRestoredFocus?: boolean; }; // Returned by the `useNavigator` hook. export type Navigator = { + /** + * The current location. + */ location: NavigatorLocation; + /** + * Params associated with the current location + */ params: MatchParams; + /** + * Navigate to a new location. + */ goTo: ( path: string, options?: NavigateOptions ) => void; - goBack: () => void; + /** + * Go back to the parent location (ie. "/some/path" will navigate back + * to "/some") + */ + goBack: ( options?: NavigateToParentOptions ) => void; + /** + * _Note: This function is deprecated. Please use `goBack` instead._ + * @deprecated + * @ignore + */ goToParent: ( options?: NavigateToParentOptions ) => void; }; @@ -64,15 +108,6 @@ export type NavigatorScreenProps = { export type NavigatorBackButtonProps = ButtonAsButtonProps; -export type NavigatorBackButtonHookProps = NavigatorBackButtonProps & { - /** - * Whether we should navigate to the parent screen. - * - * @default 'false' - */ - goToParent?: boolean; -}; - export type NavigatorToParentButtonProps = NavigatorBackButtonProps; export type NavigatorButtonProps = NavigatorBackButtonProps & { diff --git a/packages/components/src/palette-edit/styles.ts b/packages/components/src/palette-edit/styles.ts index aa4ed720b93bfe..ad918d8590cf23 100644 --- a/packages/components/src/palette-edit/styles.ts +++ b/packages/components/src/palette-edit/styles.ts @@ -31,7 +31,7 @@ export const IndicatorStyled = styled( ColorIndicator )` export const NameInputControl = styled( InputControl )` ${ InputControlContainer } { background: ${ COLORS.gray[ 100 ] }; - border-radius: ${ CONFIG.controlBorderRadius }; + border-radius: ${ CONFIG.radiusXSmall }; ${ Input }${ Input }${ Input }${ Input } { height: ${ space( 8 ) }; } @@ -85,8 +85,8 @@ export const PaletteItem = styled( View )` outline-offset: 0; } - border-top-left-radius: ${ CONFIG.controlBorderRadius }; - border-top-right-radius: ${ CONFIG.controlBorderRadius }; + border-top-left-radius: ${ CONFIG.radiusSmall }; + border-top-right-radius: ${ CONFIG.radiusSmall }; & + & { border-top-left-radius: 0; @@ -94,8 +94,8 @@ export const PaletteItem = styled( View )` } &:last-child { - border-bottom-left-radius: ${ CONFIG.controlBorderRadius }; - border-bottom-right-radius: ${ CONFIG.controlBorderRadius }; + border-bottom-left-radius: ${ CONFIG.radiusSmall }; + border-bottom-right-radius: ${ CONFIG.radiusSmall }; border-bottom-color: ${ CONFIG.surfaceBorderColor }; } diff --git a/packages/components/src/placeholder/style.scss b/packages/components/src/placeholder/style.scss index e046ce1e3a427e..61090c81110a5c 100644 --- a/packages/components/src/placeholder/style.scss +++ b/packages/components/src/placeholder/style.scss @@ -19,7 +19,7 @@ // Block UI appearance. - border-radius: $radius-block-ui; + border-radius: $radius-medium; background-color: $white; box-shadow: inset 0 0 0 $border-width $gray-900; outline: 1px solid transparent; // Shown for Windows 10 High Contrast mode. diff --git a/packages/components/src/popover/index.tsx b/packages/components/src/popover/index.tsx index 68d331ce8266ba..3005f13c952ec0 100644 --- a/packages/components/src/popover/index.tsx +++ b/packages/components/src/popover/index.tsx @@ -113,8 +113,9 @@ const UnforwardedPopover = ( WordPressComponentProps< PopoverProps, 'div', false >, // To avoid overlaps between the standard HTML attributes and the props // expected by `framer-motion`, omit all framer motion props from popover - // props (except for `animate` and `children`, which are re-defined in `PopoverProps`). - keyof Omit< MotionProps, 'animate' | 'children' > + // props (except for `animate` and `children` which are re-defined in + // `PopoverProps`, and `style` which is merged safely). + keyof Omit< MotionProps, 'animate' | 'children' | 'style' > >, forwardedRef: ForwardedRef< any > ) => { @@ -139,6 +140,7 @@ const UnforwardedPopover = ( shift = false, inline = false, variant, + style: contentStyle, // Deprecated props __unstableForcePosition, @@ -370,6 +372,7 @@ const UnforwardedPopover = ( const animationProps: HTMLMotionProps< 'div' > = shouldAnimate ? { style: { + ...contentStyle, ...motionInlineStyles, ...style, }, @@ -378,7 +381,10 @@ const UnforwardedPopover = ( } : { animate: false, - style, + style: { + ...contentStyle, + ...style, + }, }; // When Floating UI has finished positioning and Framer Motion has finished animating diff --git a/packages/components/src/popover/style.scss b/packages/components/src/popover/style.scss index c7ff0510986bfa..f9f43870a6b927 100644 --- a/packages/components/src/popover/style.scss +++ b/packages/components/src/popover/style.scss @@ -23,7 +23,7 @@ $shadow-popover-border-top-only-alternate: 0 #{-$border-width} 0 $gray-900; .components-popover__content { background: $white; box-shadow: $shadow-popover-border-default, $shadow-popover; - border-radius: $radius-block-ui; + border-radius: $radius-medium; box-sizing: border-box; width: min-content; diff --git a/packages/components/src/popover/test/index.tsx b/packages/components/src/popover/test/index.tsx index f5ee7e96e4c54d..eac0f942df2f6e 100644 --- a/packages/components/src/popover/test/index.tsx +++ b/packages/components/src/popover/test/index.tsx @@ -179,6 +179,40 @@ describe( 'Popover', () => { } ); } ); + describe( 'style', () => { + it( 'outputs inline styles added through the style prop in addition to built-in popover positioning styles', async () => { + render( + + Hello + + ); + const popover = screen.getByTestId( 'popover-element' ); + + await waitFor( () => expect( popover ).toBeVisible() ); + expect( popover ).toHaveStyle( + 'position: absolute; top: 0px; left: 0px; z-index: 0;' + ); + } ); + + it( 'is not possible to override built-in popover positioning styles via the style prop', async () => { + render( + + Hello + + ); + const popover = screen.getByTestId( 'popover-element' ); + + await waitFor( () => expect( popover ).toBeVisible() ); + expect( popover ).not.toHaveStyle( 'position: static;' ); + } ); + } ); + describe( 'focus behavior', () => { it( 'should focus the popover container when opened', async () => { render( diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index 5ff39ba364a041..699911e5ba046b 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -1,13 +1,7 @@ /** * Internal dependencies */ -import { - Composite as CompositeV2, - CompositeGroup as CompositeGroupV2, - CompositeItem as CompositeItemV2, - CompositeRow as CompositeRowV2, - useCompositeStore as useCompositeStoreV2, -} from './composite/v2'; +import { Composite, useCompositeStore } from './composite'; import { positionToPlacement as __experimentalPopoverLegacyPositionToPlacement } from './popover/utils'; import { createPrivateSlotFill } from './slot-fill'; import { @@ -28,11 +22,11 @@ import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { - CompositeV2, - CompositeGroupV2, - CompositeItemV2, - CompositeRowV2, - useCompositeStoreV2, + CompositeV2: Composite, + CompositeGroupV2: Composite.Group, + CompositeItemV2: Composite.Item, + CompositeRowV2: Composite.Row, + useCompositeStoreV2: useCompositeStore, __experimentalPopoverLegacyPositionToPlacement, createPrivateSlotFill, ComponentsContext, diff --git a/packages/components/src/progress-bar/styles.ts b/packages/components/src/progress-bar/styles.ts index 79b9103e73a1ed..585b9ab2620753 100644 --- a/packages/components/src/progress-bar/styles.ts +++ b/packages/components/src/progress-bar/styles.ts @@ -40,7 +40,7 @@ export const Track = styled.div` ${ COLORS.theme.foreground }, transparent 90% ); - border-radius: ${ CONFIG.radiusBlockUi }; + border-radius: ${ CONFIG.radiusFull }; // Windows high contrast mode. outline: 2px solid transparent; @@ -58,7 +58,7 @@ export const Indicator = styled.div< { position: absolute; top: 0; height: 100%; - border-radius: ${ CONFIG.radiusBlockUi }; + border-radius: ${ CONFIG.radiusFull }; /* Text color at 90% opacity */ background-color: color-mix( in srgb, diff --git a/packages/components/src/query-controls/index.tsx b/packages/components/src/query-controls/index.tsx index 3557335ebac5a0..452dd303c778bb 100644 --- a/packages/components/src/query-controls/index.tsx +++ b/packages/components/src/query-controls/index.tsx @@ -60,7 +60,6 @@ function isMultipleCategorySelection( * ``` */ export function QueryControls( { - __next40pxDefaultSize = false, authorList, selectedAuthorId, numberOfItems, @@ -82,7 +81,7 @@ export function QueryControls( { onOrderChange && onOrderByChange && ( = ( { onChange, ...args } ) => { export const Default: StoryFn< typeof RangeControl > = Template.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, help: 'Please select how transparent you would like this.', initialPosition: 50, label: 'Opacity', @@ -104,6 +105,7 @@ export const WithAnyStep: StoryFn< typeof RangeControl > = ( { ); }; WithAnyStep.args = { + __nextHasNoMarginBottom: true, label: 'Brightness', step: 'any', }; @@ -167,6 +169,7 @@ export const WithIntegerStepAndMarks: StoryFn< typeof RangeControl > = MarkTemplate.bind( {} ); WithIntegerStepAndMarks.args = { + __nextHasNoMarginBottom: true, label: 'Integer Step', marks: marksBase, max: 10, @@ -183,6 +186,7 @@ export const WithDecimalStepAndMarks: StoryFn< typeof RangeControl > = MarkTemplate.bind( {} ); WithDecimalStepAndMarks.args = { + __nextHasNoMarginBottom: true, marks: [ ...marksBase, { value: 3.5, label: '3.5' }, @@ -202,6 +206,7 @@ export const WithNegativeMinimumAndMarks: StoryFn< typeof RangeControl > = MarkTemplate.bind( {} ); WithNegativeMinimumAndMarks.args = { + __nextHasNoMarginBottom: true, marks: marksWithNegatives, max: 10, min: -10, @@ -217,6 +222,7 @@ export const WithNegativeRangeAndMarks: StoryFn< typeof RangeControl > = MarkTemplate.bind( {} ); WithNegativeRangeAndMarks.args = { + __nextHasNoMarginBottom: true, marks: marksWithNegatives, max: -1, min: -10, @@ -232,6 +238,7 @@ export const WithAnyStepAndMarks: StoryFn< typeof RangeControl > = MarkTemplate.bind( {} ); WithAnyStepAndMarks.args = { + __nextHasNoMarginBottom: true, marks: marksBase, max: 10, min: 0, diff --git a/packages/components/src/range-control/styles/range-control-styles.ts b/packages/components/src/range-control/styles/range-control-styles.ts index 89f4864aee2ea6..ec1572d2679247 100644 --- a/packages/components/src/range-control/styles/range-control-styles.ts +++ b/packages/components/src/range-control/styles/range-control-styles.ts @@ -154,7 +154,7 @@ export const Mark = styled.span` height: ${ thumbSize }px; left: 0; position: absolute; - top: -4px; + top: 9px; width: 1px; ${ markFill }; @@ -170,7 +170,7 @@ export const MarkLabel = styled.span` color: ${ COLORS.gray[ 300 ] }; font-size: 11px; position: absolute; - top: 12px; + top: 22px; white-space: nowrap; ${ rtl( { left: 0 } ) }; diff --git a/packages/components/src/range-control/test/index.tsx b/packages/components/src/range-control/test/index.tsx index d843b615ed0078..a4c5d8c6f2bc7f 100644 --- a/packages/components/src/range-control/test/index.tsx +++ b/packages/components/src/range-control/test/index.tsx @@ -6,7 +6,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; /** * Internal dependencies */ -import RangeControl from '../'; +import _RangeControl from '../'; const getRangeInput = (): HTMLInputElement => screen.getByRole( 'slider' ); const getNumberInput = (): HTMLInputElement => screen.getByRole( 'spinbutton' ); @@ -15,6 +15,12 @@ const getResetButton = (): HTMLButtonElement => screen.getByRole( 'button' ); const fireChangeEvent = ( input: HTMLInputElement, value?: number | string ) => fireEvent.change( input, { target: { value } } ); +const RangeControl = ( + props: React.ComponentProps< typeof _RangeControl > +) => { + return <_RangeControl { ...props } __nextHasNoMarginBottom />; +}; + describe( 'RangeControl', () => { describe( '#render()', () => { it( 'should trigger change callback with numeric value', () => { diff --git a/packages/components/src/search-control/index.tsx b/packages/components/src/search-control/index.tsx index 08cb3b065c904e..aac905e137e025 100644 --- a/packages/components/src/search-control/index.tsx +++ b/packages/components/src/search-control/index.tsx @@ -77,10 +77,13 @@ function UnforwardedSearchControl( const contextValue = useMemo( () => ( { - // Overrides the underlying BaseControl `__nextHasNoMarginBottom` via the context system - // to provide backwards compatibile margin for SearchControl. - // (In a standard InputControl, the BaseControl `__nextHasNoMarginBottom` is always set to true.) - BaseControl: { _overrides: { __nextHasNoMarginBottom } }, + BaseControl: { + // Overrides the underlying BaseControl `__nextHasNoMarginBottom` via the context system + // to provide backwards compatibile margin for SearchControl. + // (In a standard InputControl, the BaseControl `__nextHasNoMarginBottom` is always set to true.) + _overrides: { __nextHasNoMarginBottom }, + __associatedWPComponentName: 'SearchControl', + }, // `isBorderless` is still experimental and not a public prop for InputControl yet. InputBase: { isBorderless: true }, } ), diff --git a/packages/components/src/search-control/stories/index.story.tsx b/packages/components/src/search-control/stories/index.story.tsx index 433d3eef655adf..215288bb67c9b6 100644 --- a/packages/components/src/search-control/stories/index.story.tsx +++ b/packages/components/src/search-control/stories/index.story.tsx @@ -48,6 +48,7 @@ const Template: StoryFn< typeof SearchControl > = ( { export const Default = Template.bind( {} ); Default.args = { help: 'Help text to explain the input.', + __nextHasNoMarginBottom: true, }; /** diff --git a/packages/components/src/search-control/test/index.tsx b/packages/components/src/search-control/test/index.tsx index f130cab1b2a7cd..c6637945adcf63 100644 --- a/packages/components/src/search-control/test/index.tsx +++ b/packages/components/src/search-control/test/index.tsx @@ -23,6 +23,7 @@ function ControlledSearchControl( { return ( { setValue( ...args ); diff --git a/packages/components/src/select-control/index.tsx b/packages/components/src/select-control/index.tsx index ca9966fc675b86..3686661b8a58dc 100644 --- a/packages/components/src/select-control/index.tsx +++ b/packages/components/src/select-control/index.tsx @@ -99,6 +99,7 @@ function UnforwardedSelectControl< V extends string >( help={ help } id={ id } __nextHasNoMarginBottom={ __nextHasNoMarginBottom } + __associatedWPComponentName="SelectControl" > = ( props ) => { export const Default = SelectControlWithState.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, options: [ { value: '', label: 'Select an Option', disabled: true }, { value: 'a', label: 'Option A' }, @@ -82,9 +83,11 @@ WithLabelAndHelpText.args = { * As an alternative to the `options` prop, `optgroup`s and `options` can be * passed in as `children` for more customizeability. */ -export const WithCustomChildren: StoryFn< typeof SelectControl > = ( args ) => { - return ( - +export const WithCustomChildren = SelectControlWithState.bind( {} ); +WithCustomChildren.args = { + __nextHasNoMarginBottom: true, + children: ( + <> - - ); + + ), }; export const Minimal = SelectControlWithState.bind( {} ); diff --git a/packages/components/src/select-control/test/select-control.tsx b/packages/components/src/select-control/test/select-control.tsx index 0e8a6891087043..47b684cd20e280 100644 --- a/packages/components/src/select-control/test/select-control.tsx +++ b/packages/components/src/select-control/test/select-control.tsx @@ -7,7 +7,13 @@ import userEvent from '@testing-library/user-event'; /** * Internal dependencies */ -import SelectControl from '..'; +import _SelectControl from '..'; + +const SelectControl = ( + props: React.ComponentProps< typeof _SelectControl > +) => { + return <_SelectControl { ...props } __nextHasNoMarginBottom />; +}; describe( 'SelectControl', () => { it( 'should not render when no options or children are provided', () => { @@ -123,7 +129,7 @@ describe( 'SelectControl', () => { onChange={ onChange } />; - { } ); it( 'should accept an explicit type argument', () => { - + <_SelectControl< 'narrow' | 'value' > // @ts-expect-error "string" is not "narrow" or "value" value="string" options={ [ @@ -166,7 +172,7 @@ describe( 'SelectControl', () => { value: ( 'foo' | 'bar' )[] ) => void = () => {}; - { onChange={ onChange } />; - { } ); it( 'should accept an explicit type argument', () => { - + <_SelectControl< 'narrow' | 'value' > multiple // @ts-expect-error "string" is not "narrow" or "value" value={ [ 'string' ] } diff --git a/packages/components/src/snackbar/style.scss b/packages/components/src/snackbar/style.scss index 0ba1774d67382f..5bea7076599b56 100644 --- a/packages/components/src/snackbar/style.scss +++ b/packages/components/src/snackbar/style.scss @@ -3,7 +3,7 @@ font-size: $default-font-size; background: rgba($black, 0.85); // Emulates #1e1e1e closely. backdrop-filter: blur($grid-unit-20) saturate(180%); - border-radius: $radius-block-ui; + border-radius: $radius-medium; box-shadow: $shadow-popover; color: $white; padding: $grid-unit-15 ($grid-unit-05 * 5); diff --git a/packages/components/src/tab-panel/style.scss b/packages/components/src/tab-panel/style.scss index 2855f8c2b06a01..ab73a7affaeed4 100644 --- a/packages/components/src/tab-panel/style.scss +++ b/packages/components/src/tab-panel/style.scss @@ -67,7 +67,7 @@ // Draw the indicator. box-shadow: 0 0 0 0 transparent; - border-radius: $radius-block-ui; + border-radius: $radius-small; // Animation transition: all 0.1s linear; diff --git a/packages/components/src/text-control/index.tsx b/packages/components/src/text-control/index.tsx index 1643c5bc37c347..ea2d2c17bb9cf6 100644 --- a/packages/components/src/text-control/index.tsx +++ b/packages/components/src/text-control/index.tsx @@ -41,6 +41,7 @@ function UnforwardedTextControl( return ( = ( { export const Default: StoryFn< typeof TextControl > = DefaultTemplate.bind( {} ); -Default.args = {}; +Default.args = { + __nextHasNoMarginBottom: true, +}; export const WithLabelAndHelpText: StoryFn< typeof TextControl > = DefaultTemplate.bind( {} ); diff --git a/packages/components/src/text-control/test/text-control.tsx b/packages/components/src/text-control/test/text-control.tsx index fc048b93992f08..19b17cae443614 100644 --- a/packages/components/src/text-control/test/text-control.tsx +++ b/packages/components/src/text-control/test/text-control.tsx @@ -6,7 +6,11 @@ import { render, screen } from '@testing-library/react'; /** * Internal dependencies */ -import TextControl from '..'; +import _TextControl from '..'; + +const TextControl = ( props: React.ComponentProps< typeof _TextControl > ) => { + return <_TextControl { ...props } __nextHasNoMarginBottom />; +}; const noop = () => {}; diff --git a/packages/components/src/text/styles.ts b/packages/components/src/text/styles.ts index c7d48552795938..e777ed4f0941de 100644 --- a/packages/components/src/text/styles.ts +++ b/packages/components/src/text/styles.ts @@ -35,7 +35,7 @@ export const muted = css` export const highlighterText = css` mark { background: ${ COLORS.alert.yellow }; - border-radius: 2px; + border-radius: ${ CONFIG.radiusSmall }; box-shadow: 0 0 0 1px rgba( 0, 0, 0, 0.05 ) inset, 0 -1px 0 rgba( 0, 0, 0, 0.1 ) inset; diff --git a/packages/components/src/textarea-control/index.tsx b/packages/components/src/textarea-control/index.tsx index 3b96e11b0621b5..e7528510667b75 100644 --- a/packages/components/src/textarea-control/index.tsx +++ b/packages/components/src/textarea-control/index.tsx @@ -35,6 +35,7 @@ function UnforwardedTextareaControl( return ( = ( { export const Default: StoryFn< typeof TextareaControl > = Template.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, label: 'Text', help: 'Enter some text', }; diff --git a/packages/components/src/toggle-control/index.tsx b/packages/components/src/toggle-control/index.tsx index 5c64d57d3d0249..d2ee234a9695f8 100644 --- a/packages/components/src/toggle-control/index.tsx +++ b/packages/components/src/toggle-control/index.tsx @@ -10,6 +10,7 @@ import clsx from 'clsx'; */ import { forwardRef } from '@wordpress/element'; import { useInstanceId } from '@wordpress/compose'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -48,6 +49,14 @@ function UnforwardedToggleControl( ! __nextHasNoMarginBottom && css( { marginBottom: space( 3 ) } ) ); + if ( ! __nextHasNoMarginBottom ) { + deprecated( 'Bottom margin styles for wp.components.ToggleControl', { + since: '6.7', + version: '7.0', + hint: 'Set the `__nextHasNoMarginBottom` prop to true to start opting into the new styles, which will become the default in a future version.', + } ); + } + let describedBy, helpLabel; if ( help ) { if ( typeof help === 'function' ) { diff --git a/packages/components/src/toggle-control/stories/index.story.tsx b/packages/components/src/toggle-control/stories/index.story.tsx index b8043b8f48e523..97723aa207a394 100644 --- a/packages/components/src/toggle-control/stories/index.story.tsx +++ b/packages/components/src/toggle-control/stories/index.story.tsx @@ -48,6 +48,7 @@ const Template: StoryFn< typeof ToggleControl > = ( { export const Default = Template.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, label: 'Enable something', }; diff --git a/packages/components/src/toggle-control/test/index.tsx b/packages/components/src/toggle-control/test/index.tsx index cc89031d9affa3..b0eec2aca6663d 100644 --- a/packages/components/src/toggle-control/test/index.tsx +++ b/packages/components/src/toggle-control/test/index.tsx @@ -6,7 +6,13 @@ import { render, screen } from '@testing-library/react'; /** * Internal dependencies */ -import ToggleControl from '..'; +import _ToggleControl from '..'; + +const ToggleControl = ( + props: React.ComponentProps< typeof _ToggleControl > +) => { + return <_ToggleControl { ...props } __nextHasNoMarginBottom />; +}; describe( 'ToggleControl', () => { it( 'should label the toggle', () => { diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap index 81afc7ac67b05f..e9b4f4ca22ab85 100644 --- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap @@ -13,10 +13,6 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -88,7 +84,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; @@ -156,7 +152,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = .emotion-15 { background: #1e1e1e; - border-radius: 2px; + border-radius: 1px; position: absolute; inset: 0; z-index: 1; @@ -175,7 +171,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; @@ -349,10 +345,6 @@ exports[`ToggleGroupControl controlled should render correctly with text options box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -424,7 +416,7 @@ exports[`ToggleGroupControl controlled should render correctly with text options appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; @@ -573,10 +565,6 @@ exports[`ToggleGroupControl uncontrolled should render correctly with icons 1`] box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -648,7 +636,7 @@ exports[`ToggleGroupControl uncontrolled should render correctly with icons 1`] appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; @@ -716,7 +704,7 @@ exports[`ToggleGroupControl uncontrolled should render correctly with icons 1`] .emotion-15 { background: #1e1e1e; - border-radius: 2px; + border-radius: 1px; position: absolute; inset: 0; z-index: 1; @@ -735,7 +723,7 @@ exports[`ToggleGroupControl uncontrolled should render correctly with icons 1`] appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; @@ -903,10 +891,6 @@ exports[`ToggleGroupControl uncontrolled should render correctly with text optio box-sizing: inherit; } -.emotion-2 { - margin-bottom: calc(4px * 2); -} - .components-panel__row .emotion-2 { margin-bottom: inherit; } @@ -978,7 +962,7 @@ exports[`ToggleGroupControl uncontrolled should render correctly with text optio appearance: none; background: transparent; border: none; - border-radius: 2px; + border-radius: 1px; color: #757575; fill: currentColor; cursor: pointer; diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx index 661bbb9fc37bab..170db01ae523c2 100644 --- a/packages/components/src/toggle-group-control/test/index.tsx +++ b/packages/components/src/toggle-group-control/test/index.tsx @@ -15,7 +15,7 @@ import { formatLowercase, formatUppercase } from '@wordpress/icons'; */ import Button from '../../button'; import { - ToggleGroupControl, + ToggleGroupControl as _ToggleGroupControl, ToggleGroupControlOption, ToggleGroupControlOptionIcon, } from '../index'; @@ -27,6 +27,10 @@ const hoverOutside = async () => { await hover( document.body, { clientX: 10, clientY: 10 } ); }; +const ToggleGroupControl = ( props: ToggleGroupControlProps ) => { + return <_ToggleGroupControl { ...props } __nextHasNoMarginBottom />; +}; + const ControlledToggleGroupControl = ( { value: valueProp, onChange, diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts index 999a25df8bdd40..86efc5224077f4 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts @@ -37,7 +37,7 @@ export const buttonView = ( { appearance: none; background: transparent; border: none; - border-radius: ${ CONFIG.controlBorderRadius }; + border-radius: ${ CONFIG.radiusXSmall }; color: ${ COLORS.gray[ 700 ] }; fill: currentColor; cursor: pointer; @@ -122,7 +122,7 @@ const isIconStyles = ( { export const backdropView = css` background: ${ COLORS.gray[ 900 ] }; - border-radius: ${ CONFIG.controlBorderRadius }; + border-radius: ${ CONFIG.radiusXSmall }; position: absolute; inset: 0; z-index: 1; diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx index 8138b76505fe50..1c86c93548f6df 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx @@ -72,6 +72,7 @@ function UnconnectedToggleGroupControl( { ! hideLabelFromVision && ( diff --git a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts index 7310024706e1de..8d01c150a45eaf 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts @@ -19,7 +19,7 @@ export const toggleGroupControl = ( { } ) => css` background: ${ COLORS.ui.background }; border: 1px solid transparent; - border-radius: ${ CONFIG.controlBorderRadius }; + border-radius: ${ CONFIG.radiusSmall }; display: inline-flex; min-width: 0; position: relative; diff --git a/packages/components/src/toolbar/toolbar/style.scss b/packages/components/src/toolbar/toolbar/style.scss index eccfc3bf705cbd..c0cabacb84c77e 100644 --- a/packages/components/src/toolbar/toolbar/style.scss +++ b/packages/components/src/toolbar/toolbar/style.scss @@ -1,7 +1,7 @@ .components-accessible-toolbar { display: inline-flex; border: $border-width solid $gray-900; - border-radius: $radius-block-ui; + border-radius: $radius-small; flex-shrink: 0; & > .components-toolbar-group:last-child { @@ -47,7 +47,7 @@ content: ""; position: absolute; display: block; - border-radius: $radius-block-ui; + border-radius: $radius-small; height: $grid-unit-40; // Position the focus rectangle. diff --git a/packages/components/src/tools-panel/styles.ts b/packages/components/src/tools-panel/styles.ts index 1da1003c0462e3..11536e98a128a9 100644 --- a/packages/components/src/tools-panel/styles.ts +++ b/packages/components/src/tools-panel/styles.ts @@ -21,7 +21,7 @@ const toolsPanelGrid = { grid-template-columns: ${ `repeat( ${ columns }, minmax(0, 1fr) )` }; `, spacing: css` - column-gap: ${ space( 2 ) }; + column-gap: ${ space( 4 ) }; row-gap: ${ space( 4 ) }; `, item: { diff --git a/packages/components/src/tooltip/style.scss b/packages/components/src/tooltip/style.scss index feda6cfa81c887..eaac8b3ad1c7f6 100644 --- a/packages/components/src/tooltip/style.scss +++ b/packages/components/src/tooltip/style.scss @@ -1,7 +1,7 @@ .components-tooltip { background: $black; font-family: $default-font; - border-radius: $radius-block-ui; + border-radius: $radius-small; color: $gray-100; text-align: center; line-height: 1.4; diff --git a/packages/components/src/tree-select/index.tsx b/packages/components/src/tree-select/index.tsx index 599dee4402ec72..bd92807bff4cc9 100644 --- a/packages/components/src/tree-select/index.tsx +++ b/packages/components/src/tree-select/index.tsx @@ -10,6 +10,15 @@ import { decodeEntities } from '@wordpress/html-entities'; import { SelectControl } from '../select-control'; import type { TreeSelectProps, Tree, Truthy } from './types'; import { useDeprecated36pxDefaultSizeProp } from '../utils/use-deprecated-props'; +import { ContextSystemProvider } from '../context'; + +const CONTEXT_VALUE = { + BaseControl: { + // Temporary during deprecation grace period: Overrides the underlying `__associatedWPComponentName` + // via the context system to override the value set by SelectControl. + _overrides: { __associatedWPComponentName: 'TreeSelect' }, + }, +}; function getSelectOptions( tree: Tree[], @@ -91,11 +100,13 @@ export function TreeSelect( props: TreeSelectProps ) { }, [ noOptionLabel, tree ] ); return ( - + + + ); } diff --git a/packages/components/src/tree-select/stories/index.story.tsx b/packages/components/src/tree-select/stories/index.story.tsx index 0a4212dc791227..33103786bbc541 100644 --- a/packages/components/src/tree-select/stories/index.story.tsx +++ b/packages/components/src/tree-select/stories/index.story.tsx @@ -48,6 +48,7 @@ const TreeSelectWithState: StoryFn< typeof TreeSelect > = ( props ) => { export const Default = TreeSelectWithState.bind( {} ); Default.args = { + __nextHasNoMarginBottom: true, label: 'Label Text', noOptionLabel: 'No parent page', help: 'Help text to explain the select control.', diff --git a/packages/components/src/unit-control/styles/unit-control-styles.ts b/packages/components/src/unit-control/styles/unit-control-styles.ts index 321bfb8406569d..5f59771bd48a6a 100644 --- a/packages/components/src/unit-control/styles/unit-control-styles.ts +++ b/packages/components/src/unit-control/styles/unit-control-styles.ts @@ -135,7 +135,7 @@ export const UnitSelect = styled.select< SelectProps >` &&& { appearance: none; background: transparent; - border-radius: 2px; + border-radius: ${ CONFIG.radiusXSmall }; border: none; display: block; outline: none; diff --git a/packages/components/src/utils/config-values.js b/packages/components/src/utils/config-values.js index ba92813bdbfb0f..0ad1b3294a926b 100644 --- a/packages/components/src/utils/config-values.js +++ b/packages/components/src/utils/config-values.js @@ -14,7 +14,6 @@ const CONTROL_PROPS = { controlPaddingXLarge: `calc(${ CONTROL_PADDING_X } * 1.3334)`, controlPaddingXSmall: `calc(${ CONTROL_PADDING_X } / 1.3334)`, controlBackgroundColor: COLORS.white, - controlBorderRadius: '2px', controlBoxShadow: 'transparent', controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.theme.accent }`, controlDestructiveBorderColor: COLORS.alert.red, @@ -48,7 +47,6 @@ export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, { radiusLarge: '8px', radiusFull: '9999px', radiusRound: '50%', - radiusBlockUi: '2px', borderWidth: '1px', borderWidthFocus: '1.5px', borderWidthTab: '4px', @@ -73,7 +71,10 @@ export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, { cardPaddingSmall: `${ space( 4 ) }`, cardPaddingMedium: `${ space( 4 ) } ${ space( 6 ) }`, cardPaddingLarge: `${ space( 6 ) } ${ space( 8 ) }`, - popoverShadow: `0 0.7px 1px rgba(0, 0, 0, 0.1), 0 1.2px 1.7px -0.2px rgba(0, 0, 0, 0.1), 0 2.3px 3.3px -0.5px rgba(0, 0, 0, 0.1)`, + elevationXSmall: `0 0.7px 1px rgba(0, 0, 0, 0.1), 0 1.2px 1.7px -0.2px rgba(0, 0, 0, 0.1), 0 2.3px 3.3px -0.5px rgba(0, 0, 0, 0.1)`, + elevationSmall: `0 0.7px 1px 0 rgba(0, 0, 0, 0.12), 0 2.2px 3.7px -0.2px rgba(0, 0, 0, 0.12), 0 5.3px 7.3px -0.5px rgba(0, 0, 0, 0.12)`, + elevationMedium: `0 0.7px 1px 0 rgba(0, 0, 0, 0.14), 0 4.2px 5.7px -0.2px rgba(0, 0, 0, 0.14), 0 7.3px 9.3px -0.5px rgba(0, 0, 0, 0.14)`, + elevationLarge: `0 0.7px 1px rgba(0, 0, 0, 0.15), 0 2.7px 3.8px -0.2px rgba(0, 0, 0, 0.15), 0 5.5px 7.8px -0.3px rgba(0, 0, 0, 0.15), 0.1px 11.5px 16.4px -0.5px rgba(0, 0, 0, 0.15)`, surfaceBackgroundColor: COLORS.white, surfaceBackgroundSubtleColor: '#F3F3F3', surfaceBackgroundTintColor: '#F5F5F5', diff --git a/packages/components/src/utils/input/base.js b/packages/components/src/utils/input/base.js index f03a1d9c77abbe..9eebd1c0bcea78 100644 --- a/packages/components/src/utils/input/base.js +++ b/packages/components/src/utils/input/base.js @@ -11,7 +11,7 @@ import { CONFIG } from '../'; export const inputStyleNeutral = css` box-shadow: 0 0 0 transparent; - border-radius: ${ CONFIG.radiusBlockUi }; + border-radius: ${ CONFIG.radiusSmall }; border: ${ CONFIG.borderWidth } solid ${ COLORS.ui.border }; @media not ( prefers-reduced-motion ) { diff --git a/packages/core-data/src/entity-types/wp-template.ts b/packages/core-data/src/entity-types/wp-template.ts index ac6db09035f193..70d3e40c295dcf 100644 --- a/packages/core-data/src/entity-types/wp-template.ts +++ b/packages/core-data/src/entity-types/wp-template.ts @@ -73,6 +73,10 @@ declare module './base-entity-records' { * Post ID. */ wp_id: number; + /** + * Plugin that registered the template. + */ + plugin?: string; /** * Theme file exists. */ diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 239a69651a1f27..b1cb4504e72fad 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -16,6 +16,7 @@ - `setSelection` prop has been removed. Please use `onChangeSelection` instead. - `header` field property has been renamed to `label`. - `DataForm`'s `visibleFields` prop has been renamed to `fields`. +- `DataForm`'s `onChange` prop has been update to receive as argument only the fields that have changed. ### New features diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index c82a748df98858..368880b69b14f0 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -30,6 +30,8 @@ const Example = () => { }; ``` + + ## Properties ### `data`: `Object[]` @@ -72,60 +74,57 @@ const STATUSES = [ const fields = [ { id: 'title', - header: 'Title', + label: 'Title', enableHiding: false, }, { id: 'date', - header: 'Date', + label: 'Date', render: ( { item } ) => { - return ( - - ); - } + return ; + }, }, { id: 'author', - header: __( 'Author' ), + label: __( 'Author' ), render: ( { item } ) => { - return ( - { item.author } - ); + return { item.author }; }, elements: [ { value: 1, label: 'Admin' }, - { value: 2, label: 'User' } + { value: 2, label: 'User' }, ], filterBy: { - operators: [ 'is', 'isNot' ] + operators: [ 'is', 'isNot' ], }, - enableSorting: false + enableSorting: false, }, { - header: __( 'Status' ), + label: __( 'Status' ), id: 'status', getValue: ( { item } ) => - STATUSES.find( ( { value } ) => value === item.status ) - ?.label ?? item.status, + STATUSES.find( ( { value } ) => value === item.status )?.label ?? + item.status, elements: STATUSES, filterBy: { operators: [ 'isAny' ], }, enableSorting: false, }, -] +]; ``` Each field is an object with the following properties: - `id`: identifier for the field. Unique. -- `header`: the field's name to be shown in the UI. +- `label`: the field's name to be shown in the UI. - `getValue`: function that returns the value of the field, defaults to `field[id]`. - `render`: function that renders the field. Optional, `getValue` will be used if `render` is not defined. - `elements`: the set of valid values for the field's value. - `type`: the type of the field. See "Field types". - `enableSorting`: whether the data can be sorted by the given field. True by default. - `enableHiding`: whether the field can be hidden. True by default. +- `enableGlobalSearch`: whether the field is searchable. False by default. - `filterBy`: configuration for the filters. - `operators`: the list of operators supported by the field. - `isPrimary`: whether it is a primary filter. A primary filter is always visible and is not listed in the "Add filter" component, except for the list layout where it behaves like a secondary filter. @@ -255,6 +254,8 @@ Each action is an object with the following properties: - `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. +- `supportsBulk`: Whether the action can be used as a bulk action. False by default. +- `disabled`: Whether the action is disabled. False by default. ### `paginationInfo`: `Object` @@ -288,8 +289,8 @@ const defaultLayouts = { table: { layout: { primaryKey: 'my-key', - } - } + }, + }, }; ``` diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx index 4863eb24b4ede4..7147b9c2342638 100644 --- a/packages/dataviews/src/components/dataform/stories/index.story.tsx +++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx @@ -33,6 +33,20 @@ const fields = [ label: 'Order', type: 'integer' as const, }, + { + id: 'date', + label: 'Date', + type: 'datetime' as const, + }, + { + id: 'birthdate', + label: 'Date as options', + type: 'datetime' as const, + elements: [ + { value: '1970-02-23T12:00:00', label: "Jane's birth date" }, + { value: '1950-02-23T12:00:00', label: "John's birth date" }, + ], + }, { id: 'author', label: 'Author', @@ -42,6 +56,17 @@ const fields = [ { value: 2, label: 'John' }, ], }, + { + id: 'reviewer', + label: 'Reviewer', + type: 'text' as const, + Edit: 'radio' as const, + elements: [ + { value: 'fulano', label: 'Fulano' }, + { value: 'mengano', label: 'Mengano' }, + { value: 'zutano', label: 'Zutano' }, + ], + }, { id: 'status', label: 'Status', @@ -59,10 +84,21 @@ export const Default = ( { type }: { type: 'panel' | 'regular' } ) => { order: 2, author: 1, status: 'draft', + reviewer: 'fulano', + date: '2021-01-01T12:00:00', + birthdate: '1950-02-23T12:00:00', } ); const form = { - fields: [ 'title', 'order', 'author', 'status' ], + fields: [ + 'title', + 'order', + 'author', + 'reviewer', + 'status', + 'date', + 'birthdate', + ], }; return ( @@ -73,7 +109,12 @@ export const Default = ( { type }: { type: 'panel' | 'regular' } ) => { ...form, type, } } - onChange={ setPost } + onChange={ ( edits ) => + setPost( ( prev ) => ( { + ...prev, + ...edits, + } ) ) + } /> ); }; diff --git a/packages/dataviews/src/components/dataviews-pagination/index.tsx b/packages/dataviews/src/components/dataviews-pagination/index.tsx index f8ebf41469d949..f022b382cdb70d 100644 --- a/packages/dataviews/src/components/dataviews-pagination/index.tsx +++ b/packages/dataviews/src/components/dataviews-pagination/index.tsx @@ -21,10 +21,31 @@ function DataViewsPagination() { onChangeView, paginationInfo: { totalItems = 0, totalPages }, } = useContext( DataViewsContext ); + if ( ! totalItems || ! totalPages ) { return null; } + const currentPage = view.page ?? 1; + const pageSelectOptions = Array.from( Array( totalPages ) ).map( + ( _, i ) => { + const page = i + 1; + return { + value: page.toString(), + label: page.toString(), + 'aria-label': + currentPage === page + ? sprintf( + // translators: Current page number in total number of pages + __( 'Page %1$s of %2$s' ), + currentPage, + totalPages + ) + : page.toString(), + }; + } + ); + return ( !! totalItems && totalPages !== 1 && ( @@ -37,37 +58,35 @@ function DataViewsPagination() { { createInterpolateElement( sprintf( - // translators: %s: Total number of pages. - _x( 'Page of %s', 'paging' ), + // translators: 1: Current page number, 2: Total number of pages. + _x( + '
Page
%1$s
of %2$s
', + 'paging' + ), + '', totalPages ), { - CurrentPageControl: ( + div:
, + CurrentPage: ( { - const page = i + 1; - return { - value: page.toString(), - label: page.toString(), - }; - } ) } + value={ currentPage.toString() } + options={ pageSelectOptions } onChange={ ( newValue ) => { onChangeView( { ...view, page: +newValue, } ); } } - size="compact" + size="small" __nextHasNoMarginBottom + variant="minimal" /> ), } diff --git a/packages/dataviews/src/components/dataviews-pagination/style.scss b/packages/dataviews/src/components/dataviews-pagination/style.scss index 4e754ab90fa54a..16f064cc3a5178 100644 --- a/packages/dataviews/src/components/dataviews-pagination/style.scss +++ b/packages/dataviews/src/components/dataviews-pagination/style.scss @@ -5,17 +5,22 @@ background-color: $white; padding: $grid-unit-15 $grid-unit-60; border-top: $border-width solid $gray-100; - color: $gray-700; flex-shrink: 0; transition: padding ease-out 0.1s; @include reduce-motion("transition"); } -.dataviews-pagination__page-selection { +.dataviews-pagination__page-select { font-size: 11px; - text-transform: uppercase; font-weight: 500; - color: $gray-900; + text-transform: uppercase; + + @include break-small() { + .components-select-control__input { + font-size: 11px !important; + font-weight: 500; + } + } } /* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ diff --git a/packages/dataviews/src/components/dataviews-view-config/index.tsx b/packages/dataviews/src/components/dataviews-view-config/index.tsx index 28e151079008eb..e396b1c68203cc 100644 --- a/packages/dataviews/src/components/dataviews-view-config/index.tsx +++ b/packages/dataviews/src/components/dataviews-view-config/index.tsx @@ -30,11 +30,17 @@ import warning from '@wordpress/warning'; /** * Internal dependencies */ -import { SORTING_DIRECTIONS, sortIcons, sortLabels } from '../../constants'; +import { + SORTING_DIRECTIONS, + LAYOUT_GRID, + sortIcons, + sortLabels, +} from '../../constants'; import { VIEW_LAYOUTS, getMandatoryFields } from '../../dataviews-layouts'; import type { SupportedLayouts } from '../../types'; import DataViewsContext from '../dataviews-context'; import { unlock } from '../../lock-unlock'; +import DensityPicker from '../../dataviews-layouts/grid/density-picker'; const { DropdownMenuV2: DropdownMenu, @@ -101,10 +107,6 @@ function ViewTypeMenu( { ); } -interface ViewActionsProps { - defaultLayouts?: SupportedLayouts; -} - function SortFieldControl() { const { view, fields, onChangeView } = useContext( DataViewsContext ); const orderOptions = useMemo( () => { @@ -140,7 +142,11 @@ function SortFieldControl() { } function SortDirectionControl() { - const { view, onChangeView } = useContext( DataViewsContext ); + const { view, fields, onChangeView } = useContext( DataViewsContext ); + let value = view.sort?.direction; + if ( ! value && view.sort?.field ) { + value = 'desc'; + } return ( { - if ( ! view?.sort?.field ) { - return; - } if ( newDirection === 'asc' || newDirection === 'desc' ) { onChangeView( { ...view, sort: { direction: newDirection, - field: view.sort.field, + field: + view.sort?.field || + // If there is no field assigned as the sorting field assign the first sortable field. + fields.find( + ( field ) => field.enableSorting !== false + )?.id || + '', }, } ); return; @@ -239,7 +247,6 @@ function FieldControl() { { field.label }
diff --git a/packages/dataviews/src/components/dataviews/index.tsx b/packages/dataviews/src/components/dataviews/index.tsx index 337912b04e59c5..81f901f0859bbc 100644 --- a/packages/dataviews/src/components/dataviews/index.tsx +++ b/packages/dataviews/src/components/dataviews/index.tsx @@ -27,8 +27,6 @@ import DataViewsViewConfig from '../dataviews-view-config'; import { normalizeFields } from '../../normalize-fields'; import type { Action, Field, View, SupportedLayouts } from '../../types'; import type { SelectionOrUpdater } from '../../private-types'; -import DensityPicker from '../../dataviews-layouts/grid/density-picker'; -import { LAYOUT_GRID } from '../../constants'; type ItemWithId = { id: string }; @@ -133,12 +131,6 @@ export default function DataViews< Item >( { isShowingFilter={ isShowingFilter } />
- { view.type === LAYOUT_GRID && ( - - ) } ( { > { header } diff --git a/packages/dataviews/src/components/dataviews/stories/fixtures.js b/packages/dataviews/src/components/dataviews/stories/fixtures.js index 536c5e66e6ce97..f89cea81e6f15d 100644 --- a/packages/dataviews/src/components/dataviews/stories/fixtures.js +++ b/packages/dataviews/src/components/dataviews/stories/fixtures.js @@ -23,6 +23,7 @@ export const data = [ type: 'Not a planet', categories: [ 'Space', 'NASA' ], satellites: 0, + date: '2021-01-01T00:00:00Z', }, { id: 2, @@ -32,6 +33,7 @@ export const data = [ type: 'Not a planet', categories: [ 'Space' ], satellites: 0, + date: '2019-01-02T00:00:00Z', }, { id: 3, @@ -41,6 +43,7 @@ export const data = [ type: 'Not a planet', categories: [ 'NASA' ], satellites: 0, + date: '2025-01-03T00:00:00Z', }, { id: 4, @@ -50,6 +53,7 @@ export const data = [ type: 'Ice giant', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 14, + date: '2020-01-01T00:00:00Z', }, { id: 5, @@ -59,6 +63,7 @@ export const data = [ type: 'Terrestrial', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 0, + date: '2020-01-02T01:00:00Z', }, { id: 6, @@ -68,6 +73,7 @@ export const data = [ type: 'Terrestrial', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 0, + date: '2020-01-02T00:00:00Z', }, { id: 7, @@ -77,6 +83,7 @@ export const data = [ type: 'Terrestrial', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 1, + date: '2023-01-03T00:00:00Z', }, { id: 8, @@ -86,6 +93,7 @@ export const data = [ type: 'Terrestrial', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 2, + date: '2020-01-01T00:00:00Z', }, { id: 9, @@ -95,6 +103,7 @@ export const data = [ type: 'Gas giant', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 95, + date: '2017-01-01T00:01:00Z', }, { id: 10, @@ -104,6 +113,7 @@ export const data = [ type: 'Gas giant', categories: [ 'Space', 'Planet', 'Solar system' ], satellites: 146, + date: '2020-02-01T00:02:00Z', }, { id: 11, @@ -113,6 +123,7 @@ export const data = [ type: 'Ice giant', categories: [ 'Space', 'Ice giant', 'Solar system' ], satellites: 28, + date: '2020-03-01T00:00:00Z', }, ]; @@ -175,6 +186,11 @@ export const fields = [ enableHiding: false, enableGlobalSearch: true, }, + { + id: 'date', + label: 'Date', + type: 'datetime', + }, { label: 'Type', id: 'type', diff --git a/packages/dataviews/src/dataform-controls/datetime.tsx b/packages/dataviews/src/dataform-controls/datetime.tsx new file mode 100644 index 00000000000000..b31c5c60bb9c6e --- /dev/null +++ b/packages/dataviews/src/dataform-controls/datetime.tsx @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { BaseControl, TimePicker, VisuallyHidden } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; + +export default function DateTime< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label } = field; + const value = field.getValue( { item: data } ); + + const onChangeControl = useCallback( + ( newValue: string | null ) => onChange( { [ id ]: newValue } ), + [ id, onChange ] + ); + + return ( +
+ { ! hideLabelFromVision && ( + + { label } + + ) } + { hideLabelFromVision && ( + { label } + ) } + +
+ ); +} diff --git a/packages/dataviews/src/dataform-controls/index.tsx b/packages/dataviews/src/dataform-controls/index.tsx new file mode 100644 index 00000000000000..297e73c28f837c --- /dev/null +++ b/packages/dataviews/src/dataform-controls/index.tsx @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import type { ComponentType } from 'react'; + +/** + * Internal dependencies + */ +import type { + DataFormControlProps, + Field, + FieldTypeDefinition, +} from '../types'; +import datetime from './datetime'; +import integer from './integer'; +import radio from './radio'; +import select from './select'; +import text from './text'; + +interface FormControls { + [ key: string ]: ComponentType< DataFormControlProps< any > >; +} + +const FORM_CONTROLS: FormControls = { + datetime, + integer, + radio, + select, + text, +}; + +export function getControl< Item >( + field: Field< Item >, + fieldTypeDefinition: FieldTypeDefinition< Item > +) { + if ( typeof field.Edit === 'function' ) { + return field.Edit; + } + + if ( typeof field.Edit === 'string' ) { + return getControlByType( field.Edit ); + } + + if ( field.elements ) { + return getControlByType( 'select' ); + } + + if ( typeof fieldTypeDefinition.Edit === 'string' ) { + return getControlByType( fieldTypeDefinition.Edit ); + } + + return fieldTypeDefinition.Edit; +} + +export function getControlByType( type: string ) { + if ( Object.keys( FORM_CONTROLS ).includes( type ) ) { + return FORM_CONTROLS[ type ]; + } + + throw 'Control ' + type + ' not found'; +} diff --git a/packages/dataviews/src/dataform-controls/integer.tsx b/packages/dataviews/src/dataform-controls/integer.tsx new file mode 100644 index 00000000000000..f70a90ffe15239 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/integer.tsx @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { __experimentalNumberControl as NumberControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; + +export default function Integer< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label, description } = field; + const value = field.getValue( { item: data } ) ?? ''; + const onChangeControl = useCallback( + ( newValue: string | undefined ) => + onChange( { + [ id ]: Number( newValue ), + } ), + [ id, onChange ] + ); + + return ( + + ); +} diff --git a/packages/dataviews/src/dataform-controls/radio.tsx b/packages/dataviews/src/dataform-controls/radio.tsx new file mode 100644 index 00000000000000..3d616404e0c053 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/radio.tsx @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { RadioControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; + +export default function Radio< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label } = field; + const value = field.getValue( { item: data } ); + + const onChangeControl = useCallback( + ( newValue: string ) => + onChange( { + [ id ]: newValue, + } ), + [ id, onChange ] + ); + + if ( field.elements ) { + return ( + + ); + } + + return null; +} diff --git a/packages/dataviews/src/dataform-controls/select.tsx b/packages/dataviews/src/dataform-controls/select.tsx new file mode 100644 index 00000000000000..2b3bd9373fc155 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/select.tsx @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { SelectControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; + +export default function Select< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label } = field; + const value = field.getValue( { item: data } ) ?? ''; + const onChangeControl = useCallback( + ( newValue: any ) => + onChange( { + [ id ]: newValue, + } ), + [ id, onChange ] + ); + + const elements = [ + /* + * Value can be undefined when: + * + * - the field is not required + * - in bulk editing + * + */ + { label: __( 'Select item' ), value: '' }, + ...( field?.elements ?? [] ), + ]; + + return ( + + ); +} diff --git a/packages/dataviews/src/dataform-controls/style.scss b/packages/dataviews/src/dataform-controls/style.scss new file mode 100644 index 00000000000000..230826c0adf823 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/style.scss @@ -0,0 +1,4 @@ +.dataviews-controls__datetime { + border: none; + padding: 0; +} diff --git a/packages/dataviews/src/dataform-controls/text.tsx b/packages/dataviews/src/dataform-controls/text.tsx new file mode 100644 index 00000000000000..7ac095f4abede7 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/text.tsx @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { TextControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; + +export default function Text< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label, placeholder } = field; + const value = field.getValue( { item: data } ); + + const onChangeControl = useCallback( + ( newValue: string ) => + onChange( { + [ id ]: newValue, + } ), + [ id, onChange ] + ); + + return ( + + ); +} diff --git a/packages/dataviews/src/dataviews-layouts/grid/density-picker.tsx b/packages/dataviews/src/dataviews-layouts/grid/density-picker.tsx index 364d764e343470..34ddf6c3fe52f3 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/density-picker.tsx +++ b/packages/dataviews/src/dataviews-layouts/grid/density-picker.tsx @@ -1,11 +1,10 @@ /** * WordPress dependencies */ -import { RangeControl, Button } from '@wordpress/components'; +import { RangeControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useViewportMatch } from '@wordpress/compose'; -import { plus, reset } from '@wordpress/icons'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useMemo } from '@wordpress/element'; const viewportBreaks = { xhuge: { min: 3, max: 6, default: 5 }, @@ -40,21 +39,6 @@ function useViewPortBreakpoint() { return null; } -// Value is number from 0 to 100 representing how big an item is in the grid -// 100 being the biggest and 0 being the smallest. -// The size is relative to the viewport size, if one a given viewport the -// number of allowed items in a grid is 3 to 6 a 0 ( the smallest ) will mean that the grid will -// have 6 items in a row, a 100 ( the biggest ) will mean that the grid will have 3 items in a row. -// A value of 75 will mean that the grid will have 4 items in a row. -function getRangeValue( - density: number, - breakValues: { min: number; max: number; default: number } -) { - const inverseDensity = breakValues.max - density; - const max = breakValues.max - breakValues.min; - return Math.round( ( inverseDensity * 100 ) / max ); -} - export default function DensityPicker( { density, setDensity, @@ -78,59 +62,41 @@ export default function DensityPicker( { return _density; } ); }, [ setDensity, viewport ] ); + const breakValues = viewportBreaks[ viewport || 'mobile' ]; + const densityToUse = density || breakValues.default; + + const marks = useMemo( + () => + Array.from( + { length: breakValues.max - breakValues.min + 1 }, + ( _, i ) => { + return { + value: breakValues.min + i, + }; + } + ), + [ breakValues ] + ); + if ( ! viewport ) { return null; } - const breakValues = viewportBreaks[ viewport ]; - const densityToUse = density || breakValues.default; - const rangeValue = getRangeValue( densityToUse, breakValues ); - const step = 100 / ( breakValues.max - breakValues.min + 1 ); return ( - <> - - ); } diff --git a/packages/edit-site/src/components/post-fields/index.js b/packages/edit-site/src/components/post-fields/index.js index f476b676264ed8..9e59b23d61922d 100644 --- a/packages/edit-site/src/components/post-fields/index.js +++ b/packages/edit-site/src/components/post-fields/index.js @@ -42,11 +42,36 @@ import Media from '../media'; // See https://github.com/WordPress/gutenberg/issues/55886 // We do not support custom statutes at the moment. const STATUSES = [ - { value: 'draft', label: __( 'Draft' ), icon: drafts }, - { value: 'future', label: __( 'Scheduled' ), icon: scheduled }, - { value: 'pending', label: __( 'Pending Review' ), icon: pending }, - { value: 'private', label: __( 'Private' ), icon: notAllowed }, - { value: 'publish', label: __( 'Published' ), icon: published }, + { + value: 'draft', + label: __( 'Draft' ), + icon: drafts, + description: __( 'Not ready to publish.' ), + }, + { + value: 'future', + label: __( 'Scheduled' ), + icon: scheduled, + description: __( 'Publish automatically on a chosen date.' ), + }, + { + value: 'pending', + label: __( 'Pending Review' ), + icon: pending, + description: __( 'Waiting for review before publishing.' ), + }, + { + value: 'private', + label: __( 'Private' ), + icon: notAllowed, + description: __( 'Only visible to site admins and editors.' ), + }, + { + value: 'publish', + label: __( 'Published' ), + icon: published, + description: __( 'Visible to everyone.' ), + }, { value: 'trash', label: __( 'Trash' ), icon: trash }, ]; @@ -258,11 +283,10 @@ function usePostFields( viewType ) { { label: __( 'Status' ), id: 'status', - getValue: ( { item } ) => - STATUSES.find( ( { value } ) => value === item.status ) - ?.label ?? item.status, + type: 'text', elements: STATUSES, render: PostStatusField, + Edit: 'radio', enableSorting: false, filterBy: { operators: [ OPERATOR_IS_ANY ], @@ -271,6 +295,7 @@ function usePostFields( viewType ) { { label: __( 'Date' ), id: 'date', + type: 'datetime', render: ( { item } ) => { const isDraftOrPrivate = [ 'draft', 'private' ].includes( item.status @@ -344,6 +369,32 @@ function usePostFields( viewType ) { return ; }, }, + { + id: 'comment_status', + label: __( 'Discussion' ), + type: 'text', + Edit: 'radio', + enableSorting: false, + filterBy: { + operators: [], + }, + elements: [ + { + value: 'open', + label: __( 'Open' ), + description: __( + 'Visitors can add new comments and replies.' + ), + }, + { + value: 'closed', + label: __( 'Closed' ), + description: __( + 'Visitors cannot add new comments or replies. Existing comments remain visible.' + ), + }, + ], + }, ], [ authors, viewType, frontPageId, postsPageId ] ); diff --git a/packages/edit-site/src/components/post-list/index.js b/packages/edit-site/src/components/post-list/index.js index 35ecaaa8424fd4..bbfece24518495 100644 --- a/packages/edit-site/src/components/post-list/index.js +++ b/packages/edit-site/src/components/post-list/index.js @@ -366,11 +366,7 @@ export default function PostList( { postType } ) { size="compact" isPressed={ quickEdit } icon={ drawerRight } - label={ - ! quickEdit - ? __( 'Show quick edit sidebar' ) - : __( 'Close quick edit sidebar' ) - } + label={ __( 'Toggle details panel' ) } onClick={ () => { history.push( { ...location.params, diff --git a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js index aabb49c14a2ff7..69cca49fd84563 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js +++ b/packages/edit-site/src/components/sidebar-dataviews/add-new-view.js @@ -78,6 +78,8 @@ function AddNewItemModalContent( { type, setIsAdding } ) { > { + if ( key === 'backgroundImage' ) { + return ( baseConfig, userConfig ) => userConfig; + } + return undefined; + }, } ); } diff --git a/packages/editor/src/components/inserter-sidebar/index.js b/packages/editor/src/components/inserter-sidebar/index.js index 675ae5e11544bc..bf613b5c8c001a 100644 --- a/packages/editor/src/components/inserter-sidebar/index.js +++ b/packages/editor/src/components/inserter-sidebar/index.js @@ -22,6 +22,7 @@ const { PrivateInserterLibrary } = unlock( blockEditorPrivateApis ); export default function InserterSidebar() { const { + blockInsertionPoint, blockSectionRootClientId, inserterSidebarToggleRef, insertionPoint, @@ -33,8 +34,12 @@ export default function InserterSidebar() { getInsertionPoint, isPublishSidebarOpened, } = unlock( select( editorStore ) ); - const { getBlockRootClientId, __unstableGetEditorMode, getSettings } = - select( blockEditorStore ); + const { + getBlockInsertionPoint, + getBlockRootClientId, + __unstableGetEditorMode, + getSettings, + } = select( blockEditorStore ); const { get } = select( preferencesStore ); const { getActiveComplementaryArea } = select( interfaceStore ); const getBlockSectionRootClientId = () => { @@ -47,6 +52,7 @@ export default function InserterSidebar() { return getBlockRootClientId(); }; return { + blockInsertionPoint: getBlockInsertionPoint(), inserterSidebarToggleRef: getInserterSidebarToggleRef(), insertionPoint: getInsertionPoint(), showMostUsedBlocks: get( 'core', 'mostUsedBlocks' ), @@ -85,9 +91,9 @@ export default function InserterSidebar() { showInserterHelpPanel shouldFocusBlock={ isMobileViewport } rootClientId={ - blockSectionRootClientId ?? insertionPoint.rootClientId + blockSectionRootClientId ?? blockInsertionPoint.rootClientId } - __experimentalInsertionIndex={ insertionPoint.insertionIndex } + __experimentalInsertionIndex={ blockInsertionPoint.index } onSelect={ insertionPoint.onSelect } __experimentalInitialTab={ insertionPoint.tab } __experimentalInitialCategory={ insertionPoint.category } diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index 64afec2417cd05..e1c0ed1558193d 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -1,527 +1,39 @@ /** * WordPress dependencies */ -import { external } from '@wordpress/icons'; -import { addQueryArgs } from '@wordpress/url'; import { useDispatch, useSelect } from '@wordpress/data'; -import { decodeEntities } from '@wordpress/html-entities'; -import { store as coreStore } from '@wordpress/core-data'; -import { __, sprintf, _x } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; -import { useMemo, useState, useEffect } from '@wordpress/element'; -import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; -import { parse } from '@wordpress/blocks'; -import { DataForm } from '@wordpress/dataviews'; -import { - Button, - TextControl, - __experimentalHStack as HStack, - __experimentalVStack as VStack, -} from '@wordpress/components'; +import { useMemo, useEffect } from '@wordpress/element'; /** * Internal dependencies */ -import { - TEMPLATE_ORIGINS, - TEMPLATE_PART_POST_TYPE, - TEMPLATE_POST_TYPE, - PATTERN_POST_TYPE, -} from '../../store/constants'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; -import { CreateTemplatePartModalContents } from '../create-template-part-modal'; -import { getItemTitle } from '../../dataviews/actions/utils'; - -// Patterns. -const { PATTERN_TYPES, CreatePatternModalContents, useDuplicatePatternProps } = - unlock( patternsPrivateApis ); - -// TODO: this should be shared with other components (see post-fields in edit-site). -const fields = [ - { - type: 'text', - id: 'title', - label: __( 'Title' ), - placeholder: __( 'No title' ), - getValue: ( { item } ) => item.title, - }, - { - type: 'integer', - id: 'menu_order', - label: __( 'Order' ), - description: __( 'Determines the order of pages.' ), - }, -]; - -const formDuplicateAction = { - fields: [ 'title' ], -}; - -/** - * Check if a template is removable. - * - * @param {Object} template The template entity to check. - * @return {boolean} Whether the template is removable. - */ -function isTemplateRemovable( template ) { - if ( ! template ) { - return false; - } - // In patterns list page we map the templates parts to a different object - // than the one returned from the endpoint. This is why we need to check for - // two props whether is custom or has a theme file. - return ( - template?.source === TEMPLATE_ORIGINS.custom && - ! template?.has_theme_file - ); -} - -const viewPostAction = { - id: 'view-post', - label: __( 'View' ), - isPrimary: true, - icon: external, - isEligible( post ) { - return post.status !== 'trash'; - }, - callback( posts, { onActionPerformed } ) { - const post = posts[ 0 ]; - window.open( post.link, '_blank' ); - if ( onActionPerformed ) { - onActionPerformed( posts ); - } - }, -}; - -const postRevisionsAction = { - id: 'view-post-revisions', - context: 'list', - label( items ) { - const revisionsCount = - items[ 0 ]._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0; - return sprintf( - /* translators: %s: number of revisions */ - __( 'View revisions (%s)' ), - revisionsCount - ); - }, - isEligible: ( post ) => { - if ( post.status === 'trash' ) { - return false; - } - const lastRevisionId = - post?._links?.[ 'predecessor-version' ]?.[ 0 ]?.id ?? null; - const revisionsCount = - post?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0; - return lastRevisionId && revisionsCount > 1; - }, - callback( posts, { onActionPerformed } ) { - const post = posts[ 0 ]; - const href = addQueryArgs( 'revision.php', { - revision: post?._links?.[ 'predecessor-version' ]?.[ 0 ]?.id, - } ); - document.location.href = href; - if ( onActionPerformed ) { - onActionPerformed( posts ); - } - }, -}; - -const renamePostAction = { - id: 'rename-post', - label: __( 'Rename' ), - isEligible( post ) { - if ( post.status === 'trash' ) { - return false; - } - // Templates, template parts and patterns have special checks for renaming. - if ( - ! [ - TEMPLATE_POST_TYPE, - TEMPLATE_PART_POST_TYPE, - ...Object.values( PATTERN_TYPES ), - ].includes( post.type ) - ) { - return post.permissions?.update; - } - // In the case of templates, we can only rename custom templates. - if ( post.type === TEMPLATE_POST_TYPE ) { - return ( - isTemplateRemovable( post ) && - post.is_custom && - post.permissions?.update - ); - } - // Make necessary checks for template parts and patterns. - const isTemplatePart = post.type === TEMPLATE_PART_POST_TYPE; - const isUserPattern = post.type === PATTERN_TYPES.user; - // In patterns list page we map the templates parts to a different object - // than the one returned from the endpoint. This is why we need to check for - // two props whether is custom or has a theme file. - const isCustomPattern = - isUserPattern || - ( isTemplatePart && post.source === TEMPLATE_ORIGINS.custom ); - const hasThemeFile = post?.has_theme_file; - return isCustomPattern && ! hasThemeFile && post.permissions?.update; - }, - RenderModal: ( { items, closeModal, onActionPerformed } ) => { - const [ item ] = items; - const [ title, setTitle ] = useState( () => getItemTitle( item ) ); - const { editEntityRecord, saveEditedEntityRecord } = - useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - - async function onRename( event ) { - event.preventDefault(); - try { - await editEntityRecord( 'postType', item.type, item.id, { - title, - } ); - // Update state before saving rerenders the list. - setTitle( '' ); - closeModal(); - // Persist edited entity. - await saveEditedEntityRecord( 'postType', item.type, item.id, { - throwOnError: true, - } ); - createSuccessNotice( __( 'Name updated' ), { - type: 'snackbar', - } ); - onActionPerformed?.( items ); - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'An error occurred while updating the name' ); - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - } - - return ( -
- - - - - - - -
- ); - }, -}; - -const useDuplicatePostAction = ( postType ) => { - const userCanCreatePost = useSelect( - ( select ) => { - return select( coreStore ).canUser( 'create', { - kind: 'postType', - name: postType, - } ); - }, - [ postType ] - ); - return useMemo( - () => - userCanCreatePost && { - id: 'duplicate-post', - label: _x( 'Duplicate', 'action label' ), - isEligible( { status } ) { - return status !== 'trash'; - }, - RenderModal: ( { items, closeModal, onActionPerformed } ) => { - const [ item, setItem ] = useState( { - ...items[ 0 ], - title: sprintf( - /* translators: %s: Existing template title */ - __( '%s (Copy)' ), - getItemTitle( items[ 0 ] ) - ), - } ); - - const [ isCreatingPage, setIsCreatingPage ] = - useState( false ); - - const { saveEntityRecord } = useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - - async function createPage( event ) { - event.preventDefault(); - - if ( isCreatingPage ) { - return; - } - - const newItemOject = { - status: 'draft', - title: item.title, - slug: item.title || __( 'No title' ), - comment_status: item.comment_status, - content: - typeof item.content === 'string' - ? item.content - : item.content.raw, - excerpt: item.excerpt.raw, - meta: item.meta, - parent: item.parent, - password: item.password, - template: item.template, - format: item.format, - featured_media: item.featured_media, - menu_order: item.menu_order, - ping_status: item.ping_status, - }; - const assignablePropertiesPrefix = 'wp:action-assign-'; - // Get all the properties that the current user is able to assign normally author, categories, tags, - // and custom taxonomies. - const assignableProperties = Object.keys( - item?._links || {} - ) - .filter( ( property ) => - property.startsWith( - assignablePropertiesPrefix - ) - ) - .map( ( property ) => - property.slice( - assignablePropertiesPrefix.length - ) - ); - assignableProperties.forEach( ( property ) => { - if ( item[ property ] ) { - newItemOject[ property ] = item[ property ]; - } - } ); - setIsCreatingPage( true ); - try { - const newItem = await saveEntityRecord( - 'postType', - item.type, - newItemOject, - { throwOnError: true } - ); - - createSuccessNotice( - sprintf( - // translators: %s: Title of the created template e.g: "Category". - __( '"%s" successfully created.' ), - decodeEntities( - newItem.title?.rendered || item.title - ) - ), - { - id: 'duplicate-post-action', - type: 'snackbar', - } - ); - - if ( onActionPerformed ) { - onActionPerformed( [ newItem ] ); - } - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( - 'An error occurred while duplicating the page.' - ); - - createErrorNotice( errorMessage, { - type: 'snackbar', - } ); - } finally { - setIsCreatingPage( false ); - closeModal(); - } - } - - return ( -
- - - - - - - -
- ); - }, - }, - [ userCanCreatePost ] - ); -}; - -export const duplicatePatternAction = { - id: 'duplicate-pattern', - label: _x( 'Duplicate', 'action label' ), - isEligible: ( item ) => item.type !== TEMPLATE_PART_POST_TYPE, - modalHeader: _x( 'Duplicate pattern', 'action label' ), - RenderModal: ( { items, closeModal } ) => { - const [ item ] = items; - const duplicatedProps = useDuplicatePatternProps( { - pattern: item, - onSuccess: () => closeModal(), - } ); - return ( - - ); - }, -}; - -export const duplicateTemplatePartAction = { - id: 'duplicate-template-part', - label: _x( 'Duplicate', 'action label' ), - isEligible: ( item ) => item.type === TEMPLATE_PART_POST_TYPE, - modalHeader: _x( 'Duplicate template part', 'action label' ), - RenderModal: ( { items, closeModal } ) => { - const [ item ] = items; - const blocks = useMemo( () => { - return ( - item.blocks ?? - parse( - typeof item.content === 'string' - ? item.content - : item.content.raw, - { - __unstableSkipMigrationLogs: true, - } - ) - ); - }, [ item.content, item.blocks ] ); - const { createSuccessNotice } = useDispatch( noticesStore ); - function onTemplatePartSuccess() { - createSuccessNotice( - sprintf( - // translators: %s: The new template part's title e.g. 'Call to action (copy)'. - __( '"%s" duplicated.' ), - getItemTitle( item ) - ), - { type: 'snackbar', id: 'edit-site-patterns-success' } - ); - closeModal(); - } - return ( - - ); - }, -}; export function usePostActions( { postType, onActionPerformed, context } ) { - const { defaultActions, postTypeObject, userCanCreatePostType } = useSelect( + const { defaultActions } = useSelect( ( select ) => { - const { getPostType, canUser } = select( coreStore ); const { getEntityActions } = unlock( select( editorStore ) ); return { - postTypeObject: getPostType( postType ), defaultActions: getEntityActions( 'postType', postType ), - userCanCreatePostType: canUser( 'create', { - kind: 'postType', - name: postType, - } ), }; }, [ postType ] ); + const { registerPostTypeActions } = unlock( useDispatch( editorStore ) ); useEffect( () => { registerPostTypeActions( postType ); }, [ registerPostTypeActions, postType ] ); - const duplicatePostAction = useDuplicatePostAction( postType ); - const isTemplateOrTemplatePart = [ - TEMPLATE_POST_TYPE, - TEMPLATE_PART_POST_TYPE, - ].includes( postType ); - const isPattern = postType === PATTERN_POST_TYPE; - const isLoaded = !! postTypeObject; - const supportsRevisions = !! postTypeObject?.supports?.revisions; - const supportsTitle = !! postTypeObject?.supports?.title; return useMemo( () => { - if ( ! isLoaded ) { - return []; - } - - let actions = [ - postTypeObject?.viewable && viewPostAction, - supportsRevisions && postRevisionsAction, - globalThis.IS_GUTENBERG_PLUGIN - ? ! isTemplateOrTemplatePart && - ! isPattern && - duplicatePostAction - : false, - isTemplateOrTemplatePart && - userCanCreatePostType && - duplicateTemplatePartAction, - isPattern && userCanCreatePostType && duplicatePatternAction, - supportsTitle && renamePostAction, - ...defaultActions, - ].filter( Boolean ); // Filter actions based on provided context. If not provided // all actions are returned. We'll have a single entry for getting the actions // and the consumer should provide the context to filter the actions, if needed. // Actions should also provide the `context` they support, if it's specific, to // compare with the provided context to get all the actions. // Right now the only supported context is `list`. - actions = actions.filter( ( action ) => { + const actions = defaultActions.filter( ( action ) => { if ( ! action.context ) { return true; } @@ -576,17 +88,5 @@ export function usePostActions( { postType, onActionPerformed, context } ) { } return actions; - }, [ - defaultActions, - userCanCreatePostType, - isTemplateOrTemplatePart, - isPattern, - postTypeObject?.viewable, - duplicatePostAction, - onActionPerformed, - isLoaded, - supportsRevisions, - supportsTitle, - context, - ] ); + }, [ defaultActions, onActionPerformed, context ] ); } diff --git a/packages/editor/src/components/post-actions/index.js b/packages/editor/src/components/post-actions/index.js index 5b023956178938..9cc594233c8814 100644 --- a/packages/editor/src/components/post-actions/index.js +++ b/packages/editor/src/components/post-actions/index.js @@ -17,7 +17,6 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { unlock } from '../../lock-unlock'; import { usePostActions } from './actions'; -import { store as editorStore } from '../../store'; const { DropdownMenuV2: DropdownMenu, @@ -27,25 +26,23 @@ const { kebabCase, } = unlock( componentsPrivateApis ); -export default function PostActions( { onActionPerformed, buttonProps } ) { +export default function PostActions( { postType, postId, onActionPerformed } ) { const [ isActionsMenuOpen, setIsActionsMenuOpen ] = useState( false ); - const { item, permissions, postType } = useSelect( ( select ) => { - const { getCurrentPostType, getCurrentPostId } = select( editorStore ); - const { getEditedEntityRecord, getEntityRecordPermissions } = unlock( - select( coreStore ) - ); - const _postType = getCurrentPostType(); - const _id = getCurrentPostId(); - return { - item: getEditedEntityRecord( 'postType', _postType, _id ), - permissions: getEntityRecordPermissions( - 'postType', - _postType, - _id - ), - postType: _postType, - }; - }, [] ); + const { item, permissions } = useSelect( + ( select ) => { + const { getEditedEntityRecord, getEntityRecordPermissions } = + unlock( select( coreStore ) ); + return { + item: getEditedEntityRecord( 'postType', postType, postId ), + permissions: getEntityRecordPermissions( + 'postType', + postType, + postId + ), + }; + }, + [ postId, postType ] + ); const itemWithPermissions = useMemo( () => { return { ...item, @@ -76,7 +73,6 @@ export default function PostActions( { onActionPerformed, buttonProps } ) { onClick={ () => setIsActionsMenuOpen( ! isActionsMenuOpen ) } - { ...buttonProps } /> } onOpenChange={ setIsActionsMenuOpen } diff --git a/packages/editor/src/components/post-card-panel/index.js b/packages/editor/src/components/post-card-panel/index.js index 43efb6418b4fa9..ed13af9b55a4aa 100644 --- a/packages/editor/src/components/post-card-panel/index.js +++ b/packages/editor/src/components/post-card-panel/index.js @@ -26,59 +26,56 @@ import { GLOBAL_POST_TYPES, } from '../../store/constants'; import { unlock } from '../../lock-unlock'; +import PostActions from '../post-actions'; -export default function PostCardPanel( { actions } ) { +export default function PostCardPanel( { + postType, + postId, + onActionPerformed, +} ) { const { isFrontPage, isPostsPage, title, icon, isSync } = useSelect( ( select ) => { - const { - getEditedPostAttribute, - getCurrentPostType, - getCurrentPostId, - __experimentalGetTemplateInfo, - } = select( editorStore ); - const { canUser } = select( coreStore ); - const { getEditedEntityRecord } = select( coreStore ); + const { __experimentalGetTemplateInfo } = select( editorStore ); + const { canUser, getEditedEntityRecord } = select( coreStore ); const siteSettings = canUser( 'read', { kind: 'root', name: 'site', } ) ? getEditedEntityRecord( 'root', 'site' ) : undefined; - const _type = getCurrentPostType(); - const _id = getCurrentPostId(); - const _record = getEditedEntityRecord( 'postType', _type, _id ); + const _record = getEditedEntityRecord( + 'postType', + postType, + postId + ); const _templateInfo = [ TEMPLATE_POST_TYPE, TEMPLATE_PART_POST_TYPE ].includes( - _type + postType ) && __experimentalGetTemplateInfo( _record ); let _isSync = false; - if ( GLOBAL_POST_TYPES.includes( _type ) ) { - if ( PATTERN_POST_TYPE === _type ) { + if ( GLOBAL_POST_TYPES.includes( postType ) ) { + if ( PATTERN_POST_TYPE === postType ) { // When the post is first created, the top level wp_pattern_sync_status is not set so get meta value instead. const currentSyncStatus = - getEditedPostAttribute( 'meta' ) - ?.wp_pattern_sync_status === 'unsynced' + _record?.meta?.wp_pattern_sync_status === 'unsynced' ? 'unsynced' - : getEditedPostAttribute( - 'wp_pattern_sync_status' - ); + : _record?.wp_pattern_sync_status; _isSync = currentSyncStatus !== 'unsynced'; } else { _isSync = true; } } return { - title: - _templateInfo?.title || getEditedPostAttribute( 'title' ), - icon: unlock( select( editorStore ) ).getPostIcon( _type, { + title: _templateInfo?.title || _record?.title, + icon: unlock( select( editorStore ) ).getPostIcon( postType, { area: _record?.area, } ), isSync: _isSync, - isFrontPage: siteSettings?.page_on_front === _id, - isPostsPage: siteSettings?.page_for_posts === _id, + isFrontPage: siteSettings?.page_on_front === postId, + isPostsPage: siteSettings?.page_for_posts === postId, }; }, - [] + [ postId, postType ] ); return (
@@ -113,7 +110,11 @@ export default function PostCardPanel( { actions } ) { ) } - { actions } +
); diff --git a/packages/editor/src/components/post-featured-image/index.js b/packages/editor/src/components/post-featured-image/index.js index 5e1874e9b84026..febf56a46778fc 100644 --- a/packages/editor/src/components/post-featured-image/index.js +++ b/packages/editor/src/components/post-featured-image/index.js @@ -180,6 +180,8 @@ function PostFeaturedImage( { : `editor-post-featured-image-${ featuredImageId }-describedby` } aria-haspopup="dialog" + disabled={ isLoading } + accessibleWhenDisabled > { !! featuredImageId && media && (
{ showFilter && ( { - // We use isEditorPanelRemoved to hide the panel if it was programatically removed. We do - // not use isEditorPanelEnabled since this panel should not be disabled through the UI. - const { isEditorPanelRemoved, getCurrentPostType } = - select( editorStore ); - return { - isRemovedPostStatusPanel: isEditorPanelRemoved( PANEL_NAME ), - postType: getCurrentPostType(), - }; - }, [] ); + const { isRemovedPostStatusPanel, postType, postId } = useSelect( + ( select ) => { + // We use isEditorPanelRemoved to hide the panel if it was programatically removed. We do + // not use isEditorPanelEnabled since this panel should not be disabled through the UI. + const { + isEditorPanelRemoved, + getCurrentPostType, + getCurrentPostId, + } = select( editorStore ); + return { + isRemovedPostStatusPanel: isEditorPanelRemoved( PANEL_NAME ), + postType: getCurrentPostType(), + postId: getCurrentPostId(), + }; + }, + [] + ); return ( @@ -54,11 +60,9 @@ export default function PostSummary( { onActionPerformed } ) { <> - } + postType={ postType } + postId={ postId } + onActionPerformed={ onActionPerformed } /> diff --git a/packages/editor/src/dataviews/actions/duplicate-pattern.tsx b/packages/editor/src/dataviews/actions/duplicate-pattern.tsx new file mode 100644 index 00000000000000..98f43a27c3628c --- /dev/null +++ b/packages/editor/src/dataviews/actions/duplicate-pattern.tsx @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; +// @ts-ignore +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import type { Pattern } from '../types'; + +// Patterns. +const { CreatePatternModalContents, useDuplicatePatternProps } = + unlock( patternsPrivateApis ); + +const duplicatePattern: Action< Pattern > = { + id: 'duplicate-pattern', + label: _x( 'Duplicate', 'action label' ), + isEligible: ( item ) => item.type !== 'wp_template_part', + modalHeader: _x( 'Duplicate pattern', 'action label' ), + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; + const duplicatedProps = useDuplicatePatternProps( { + pattern: item, + onSuccess: () => closeModal?.(), + } ); + return ( + + ); + }, +}; + +export default duplicatePattern; diff --git a/packages/editor/src/dataviews/actions/duplicate-post.native.tsx b/packages/editor/src/dataviews/actions/duplicate-post.native.tsx new file mode 100644 index 00000000000000..5468aa649abbd4 --- /dev/null +++ b/packages/editor/src/dataviews/actions/duplicate-post.native.tsx @@ -0,0 +1,3 @@ +const duplicatePost = undefined; + +export default duplicatePost; diff --git a/packages/editor/src/dataviews/actions/duplicate-post.tsx b/packages/editor/src/dataviews/actions/duplicate-post.tsx new file mode 100644 index 00000000000000..0979d30da39519 --- /dev/null +++ b/packages/editor/src/dataviews/actions/duplicate-post.tsx @@ -0,0 +1,174 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf, _x } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useState } from '@wordpress/element'; +import { DataForm } from '@wordpress/dataviews'; +import { + Button, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { getItemTitle } from '../../dataviews/actions/utils'; +import type { CoreDataError, BasePost } from '../types'; +import { titleField } from '../fields'; + +const fields = [ titleField ]; +const formDuplicateAction = { + fields: [ 'title' ], +}; + +const duplicatePost: Action< BasePost > = { + id: 'duplicate-post', + label: _x( 'Duplicate', 'action label' ), + isEligible( { status } ) { + return status !== 'trash'; + }, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ item, setItem ] = useState< BasePost >( { + ...items[ 0 ], + title: sprintf( + /* translators: %s: Existing template title */ + __( '%s (Copy)' ), + getItemTitle( items[ 0 ] ) + ), + } ); + + const [ isCreatingPage, setIsCreatingPage ] = useState( false ); + const { saveEntityRecord } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + async function createPage( event: React.FormEvent ) { + event.preventDefault(); + + if ( isCreatingPage ) { + return; + } + + const newItemOject = { + status: 'draft', + title: item.title, + slug: item.title || __( 'No title' ), + comment_status: item.comment_status, + content: + typeof item.content === 'string' + ? item.content + : item.content.raw, + excerpt: + typeof item.excerpt === 'string' + ? item.excerpt + : item.excerpt?.raw, + meta: item.meta, + parent: item.parent, + password: item.password, + template: item.template, + format: item.format, + featured_media: item.featured_media, + menu_order: item.menu_order, + ping_status: item.ping_status, + }; + const assignablePropertiesPrefix = 'wp:action-assign-'; + // Get all the properties that the current user is able to assign normally author, categories, tags, + // and custom taxonomies. + const assignableProperties = Object.keys( item?._links || {} ) + .filter( ( property ) => + property.startsWith( assignablePropertiesPrefix ) + ) + .map( ( property ) => + property.slice( assignablePropertiesPrefix.length ) + ); + assignableProperties.forEach( ( property ) => { + if ( item.hasOwnProperty( property ) ) { + // @ts-ignore + newItemOject[ property ] = item[ property ]; + } + } ); + setIsCreatingPage( true ); + try { + const newItem = await saveEntityRecord( + 'postType', + item.type, + newItemOject, + { throwOnError: true } + ); + + createSuccessNotice( + sprintf( + // translators: %s: Title of the created template e.g: "Category". + __( '"%s" successfully created.' ), + decodeEntities( newItem.title?.rendered || item.title ) + ), + { + id: 'duplicate-post-action', + type: 'snackbar', + } + ); + + if ( onActionPerformed ) { + onActionPerformed( [ newItem ] ); + } + } catch ( error ) { + const typedError = error as CoreDataError; + const errorMessage = + typedError.message && typedError.code !== 'unknown_error' + ? typedError.message + : __( 'An error occurred while duplicating the page.' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } finally { + setIsCreatingPage( false ); + closeModal?.(); + } + } + + return ( +
+ + + setItem( ( prev ) => ( { + ...prev, + ...changes, + } ) ) + } + /> + + + + + +
+ ); + }, +}; + +export default duplicatePost; diff --git a/packages/editor/src/dataviews/actions/duplicate-template-part.tsx b/packages/editor/src/dataviews/actions/duplicate-template-part.tsx new file mode 100644 index 00000000000000..fa3cf39ba76268 --- /dev/null +++ b/packages/editor/src/dataviews/actions/duplicate-template-part.tsx @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { __, sprintf, _x } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useMemo } from '@wordpress/element'; +// @ts-ignore +import { parse } from '@wordpress/blocks'; +import type { Action } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { TEMPLATE_PART_POST_TYPE } from '../../store/constants'; +import { CreateTemplatePartModalContents } from '../../components/create-template-part-modal'; +import { getItemTitle } from './utils'; +import type { TemplatePart } from '../types'; + +const duplicateTemplatePart: Action< TemplatePart > = { + id: 'duplicate-template-part', + label: _x( 'Duplicate', 'action label' ), + isEligible: ( item ) => item.type === TEMPLATE_PART_POST_TYPE, + modalHeader: _x( 'Duplicate template part', 'action label' ), + RenderModal: ( { items, closeModal } ) => { + const [ item ] = items; + const blocks = useMemo( () => { + return ( + item.blocks ?? + parse( + typeof item.content === 'string' + ? item.content + : item.content.raw, + { + __unstableSkipMigrationLogs: true, + } + ) + ); + }, [ item.content, item.blocks ] ); + const { createSuccessNotice } = useDispatch( noticesStore ); + function onTemplatePartSuccess() { + createSuccessNotice( + sprintf( + // translators: %s: The new template part's title e.g. 'Call to action (copy)'. + __( '"%s" duplicated.' ), + getItemTitle( item ) + ), + { type: 'snackbar', id: 'edit-site-patterns-success' } + ); + closeModal?.(); + } + return ( + + ); + }, +}; + +export default duplicateTemplatePart; diff --git a/packages/editor/src/dataviews/actions/rename-post.tsx b/packages/editor/src/dataviews/actions/rename-post.tsx new file mode 100644 index 00000000000000..ef9da271111ea2 --- /dev/null +++ b/packages/editor/src/dataviews/actions/rename-post.tsx @@ -0,0 +1,146 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +// @ts-ignore +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import { + Button, + TextControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import type { Action } from '@wordpress/dataviews'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { + TEMPLATE_ORIGINS, + TEMPLATE_PART_POST_TYPE, + TEMPLATE_POST_TYPE, +} from '../../store/constants'; +import { unlock } from '../../lock-unlock'; +import { + getItemTitle, + isTemplateRemovable, + isTemplate, + isTemplatePart, +} from './utils'; +import type { CoreDataError, PostWithPermissions } from '../types'; + +// Patterns. +const { PATTERN_TYPES } = unlock( patternsPrivateApis ); + +const renamePost: Action< PostWithPermissions > = { + id: 'rename-post', + label: __( 'Rename' ), + isEligible( post ) { + if ( post.status === 'trash' ) { + return false; + } + // Templates, template parts and patterns have special checks for renaming. + if ( + ! [ + TEMPLATE_POST_TYPE, + TEMPLATE_PART_POST_TYPE, + ...Object.values( PATTERN_TYPES ), + ].includes( post.type ) + ) { + return post.permissions?.update; + } + + // In the case of templates, we can only rename custom templates. + if ( isTemplate( post ) ) { + return ( + isTemplateRemovable( post ) && + post.is_custom && + post.permissions?.update + ); + } + + if ( isTemplatePart( post ) ) { + return ( + post.source === TEMPLATE_ORIGINS.custom && + ! post?.has_theme_file && + post.permissions?.update + ); + } + + return post.type === PATTERN_TYPES.user && post.permissions?.update; + }, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ item ] = items; + const [ title, setTitle ] = useState( () => getItemTitle( item ) ); + const { editEntityRecord, saveEditedEntityRecord } = + useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + + async function onRename( event: React.FormEvent ) { + event.preventDefault(); + try { + await editEntityRecord( 'postType', item.type, item.id, { + title, + } ); + // Update state before saving rerenders the list. + setTitle( '' ); + closeModal?.(); + // Persist edited entity. + await saveEditedEntityRecord( 'postType', item.type, item.id, { + throwOnError: true, + } ); + createSuccessNotice( __( 'Name updated' ), { + type: 'snackbar', + } ); + onActionPerformed?.( items ); + } catch ( error ) { + const typedError = error as CoreDataError; + const errorMessage = + typedError.message && typedError.code !== 'unknown_error' + ? typedError.message + : __( 'An error occurred while updating the name' ); + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + + return ( +
+ + + + + + + +
+ ); + }, +}; + +export default renamePost; diff --git a/packages/editor/src/dataviews/actions/reorder-page.tsx b/packages/editor/src/dataviews/actions/reorder-page.tsx index 9193904f0f912c..1820884d8d8c73 100644 --- a/packages/editor/src/dataviews/actions/reorder-page.tsx +++ b/packages/editor/src/dataviews/actions/reorder-page.tsx @@ -17,7 +17,7 @@ import type { Action, RenderModalProps } from '@wordpress/dataviews'; /** * Internal dependencies */ -import type { CoreDataError, PostWithPageAttributesSupport } from '../types'; +import type { CoreDataError, BasePost } from '../types'; import { orderField } from '../fields'; const fields = [ orderField ]; @@ -29,7 +29,7 @@ function ReorderModal( { items, closeModal, onActionPerformed, -}: RenderModalProps< PostWithPageAttributesSupport > ) { +}: RenderModalProps< BasePost > ) { const [ item, setItem ] = useState( items[ 0 ] ); const orderInput = item.menu_order; const { editEntityRecord, saveEditedEntityRecord } = @@ -81,7 +81,12 @@ function ReorderModal( { data={ item } fields={ fields } form={ formOrderAction } - onChange={ setItem } + onChange={ ( changes ) => + setItem( { + ...item, + ...changes, + } ) + } />