From 7050a44afb90ee187e2ed401ee1d99b8de2ad34f Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Fri, 13 Dec 2024 08:55:35 +0000
Subject: [PATCH 01/66] Fix: Fix link to minimal-block example plugin code.
(#67888)
Co-authored-by: jorgefilipecosta
Co-authored-by: shail-mehta
---
docs/getting-started/fundamentals/registration-of-a-block.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/getting-started/fundamentals/registration-of-a-block.md b/docs/getting-started/fundamentals/registration-of-a-block.md
index 5c80422f6f857..63a7a9031f72a 100644
--- a/docs/getting-started/fundamentals/registration-of-a-block.md
+++ b/docs/getting-started/fundamentals/registration-of-a-block.md
@@ -42,7 +42,7 @@ function minimal_block_ca6eda___register_block() {
add_action( 'init', 'minimal_block_ca6eda___register_block' );
```
-_See the [full block example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/minimal-block-ca6eda) of the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/minimal-block-ca6eda/index.php)_
+_See the [full block example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/minimal-block-ca6eda) of the [code above](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/minimal-block-ca6eda/plugin.php)_
## Registering a block with JavaScript (client-side)
From 7e85993a0be6830a398515513b8c42366cbcd2b4 Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Fri, 13 Dec 2024 13:18:26 +0400
Subject: [PATCH 02/66] Plugin: Fix eligibility check for post types' default
rendering mode (#67879)
* Plugin: Fix eligibility check for post types' default rendering mode
* Add backport changelog entry
Unlinked contributors: CreativeDive.
Co-authored-by: Mamaduka
Co-authored-by: fabiankaegy
---
backport-changelog/6.8/7129.md | 1 +
lib/compat/wordpress-6.8/post.php | 9 ++++++---
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/backport-changelog/6.8/7129.md b/backport-changelog/6.8/7129.md
index 90c9168cdc6f8..301f1abc45d0d 100644
--- a/backport-changelog/6.8/7129.md
+++ b/backport-changelog/6.8/7129.md
@@ -1,3 +1,4 @@
https://github.com/WordPress/wordpress-develop/pull/7129
* https://github.com/WordPress/gutenberg/pull/62304
+* https://github.com/WordPress/gutenberg/pull/67879
diff --git a/lib/compat/wordpress-6.8/post.php b/lib/compat/wordpress-6.8/post.php
index 639e33b4e5ca5..be842d89b5151 100644
--- a/lib/compat/wordpress-6.8/post.php
+++ b/lib/compat/wordpress-6.8/post.php
@@ -32,15 +32,18 @@ function gutenberg_post_type_rendering_modes() {
* @return array Updated array of post type arguments.
*/
function gutenberg_post_type_default_rendering_mode( $args, $post_type ) {
- $rendering_mode = 'page' === $post_type ? 'template-locked' : 'post-only';
- $rendering_modes = gutenberg_post_type_rendering_modes();
+ if ( ! wp_is_block_theme() || ! current_theme_supports( 'block-templates' ) ) {
+ return $args;
+ }
// Make sure the post type supports the block editor.
if (
- wp_is_block_theme() &&
( isset( $args['show_in_rest'] ) && $args['show_in_rest'] ) &&
( ! empty( $args['supports'] ) && in_array( 'editor', $args['supports'], true ) )
) {
+ $rendering_mode = 'page' === $post_type ? 'template-locked' : 'post-only';
+ $rendering_modes = gutenberg_post_type_rendering_modes();
+
// Validate the supplied rendering mode.
if (
isset( $args['default_rendering_mode'] ) &&
From 25e9753bfb4884ddcf02855f47cef3eb418eaea9 Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Fri, 13 Dec 2024 10:01:50 +0000
Subject: [PATCH 03/66] [Docs] Fix: Two broken links to the packages reference
API and to blocks docs (#67889)
Co-authored-by: jorgefilipecosta
Co-authored-by: shail-mehta
---
.../fundamentals/javascript-in-the-block-editor.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md
index 348b95ba88da3..7accc5d4c2129 100644
--- a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md
+++ b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md
@@ -38,9 +38,9 @@ The `wp-scripts` package also facilitates the use of JavaScript modules, allowin
Integrating JavaScript into your WordPress projects without a build process can be the most straightforward approach in specific scenarios. This is particularly true for projects that don't leverage JSX or other advanced JavaScript features requiring compilation.
-When you opt out of a build process, you interact directly with WordPress's [JavaScript APIs](/docs/reference-guides/packages/) through the global `wp` object. This means that all the methods and packages provided by WordPress are readily available, but with one caveat: you must manually manage script dependencies. This is done by adding [the handle](/docs/contributors/code/scripts.md) of each corresponding package to the dependency array of your enqueued JavaScript file.
+When you opt out of a build process, you interact directly with WordPress's [JavaScript APIs](/docs/reference-guides/packages.md) through the global `wp` object. This means that all the methods and packages provided by WordPress are readily available, but with one caveat: you must manually manage script dependencies. This is done by adding [the handle](/docs/contributors/code/scripts.md) of each corresponding package to the dependency array of your enqueued JavaScript file.
-For example, suppose you're creating a script that registers a new block [variation](/docs/reference-guides/block-api/block-variations.md) using the `registerBlockVariation` function from the [`blocks`](/docs/reference-guides/packages/packages-blocks.md) package. You must include `wp-blocks` in your script's dependency array. This guarantees that the `wp.blocks.registerBlockVariation` method is available and defined by the time your script executes.
+For example, suppose you're creating a script that registers a new block [variation](/docs/reference-guides/block-api/block-variations.md) using the `registerBlockVariation` function from the [`blocks`](/packages/blocks/README.md) package. You must include `wp-blocks` in your script's dependency array. This guarantees that the `wp.blocks.registerBlockVariation` method is available and defined by the time your script executes.
In the following example, the `wp-blocks` dependency is defined when enqueuing the `variations.js` file.
From 20f41746aebc0a4c736879419ea9cb7229de08f9 Mon Sep 17 00:00:00 2001
From: Jarda Snajdr
Date: Fri, 13 Dec 2024 11:26:54 +0100
Subject: [PATCH 04/66] Create a catalog list of private APIs (#66558)
* Create a catalog list of private APIs
* Document some private components
* Rewrite the introduction
* Rewrite the introduction again
---
docs/private-apis.md | 340 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 340 insertions(+)
create mode 100644 docs/private-apis.md
diff --git a/docs/private-apis.md b/docs/private-apis.md
new file mode 100644
index 0000000000000..14c1a4aa22472
--- /dev/null
+++ b/docs/private-apis.md
@@ -0,0 +1,340 @@
+# Gutenberg Private APIs
+
+This is an overview of private APIs exposed by Gutenberg packages. These APIs are used to implement parts of the Gutenberg editor (Post Editor, Site Editor, Core blocks and others) but are not exposed publicly to plugin and theme authors or authors of custom Gutenberg integrations.
+
+The purpose of this document is to present a picture of how many private APIs we have and how they are used to build the Gutenberg editor apps with the libraries and frameworks provided by the family of `@wordpress/*` packages.
+
+## data
+
+The registry has two private methods:
+- `privateActionsOf`
+- `privateSelectorsOf`
+
+Every store has a private API for registering private selectors/actions:
+- `privateActions`
+- `registerPrivateActions`
+- `privateSelectors`
+- `registerPrivateSelectors`
+
+## blocks
+
+### `core/blocks` store
+
+Private actions:
+- `addBlockBindingsSource`
+- `removeBlockBindingsSource`
+- `addBootstrappedBlockType`
+- `addUnprocessedBlockType`
+
+Private selectors:
+- `getAllBlockBindingsSources`
+- `getBlockBindingsSource`
+- `getBootstrappedBlockType`
+- `getSupportedStyles`
+- `getUnprocessedBlockTypes`
+- `hasContentRoleAttribute`
+
+## components
+
+Private exports:
+- `__experimentalPopoverLegacyPositionToPlacement`
+- `ComponentsContext`
+- `Tabs`
+- `Theme`
+- `Menu`
+- `kebabCase`
+
+## commands
+
+Private exports:
+- `useCommandContext` (added May 2023 in #50543)
+
+### `core/commands` store
+
+Private actions:
+- `setContext` (added together with `useCommandContext`)
+
+## preferences
+
+Private exports: (added in Jan 2024 in #57639)
+- `PreferenceBaseOption`
+- `PreferenceToggleControl`
+- `PreferencesModal`
+- `PreferencesModalSection`
+- `PreferencesModalTabs`
+
+There is only one publicly exported component!
+- `PreferenceToggleMenuItem`
+
+## block-editor
+
+Private exports:
+- `AdvancedPanel`
+- `BackgroundPanel`
+- `BorderPanel`
+- `ColorPanel`
+- `DimensionsPanel`
+- `FiltersPanel`
+- `GlobalStylesContext`
+- `ImageSettingsPanel`
+- `TypographyPanel`
+- `areGlobalStyleConfigsEqual`
+- `getBlockCSSSelector`
+- `getBlockSelectors`
+- `getGlobalStylesChanges`
+- `getLayoutStyles`
+- `toStyles`
+- `useGlobalSetting`
+- `useGlobalStyle`
+- `useGlobalStylesOutput`
+- `useGlobalStylesOutputWithConfig`
+- `useGlobalStylesReset`
+- `useHasBackgroundPanel`
+- `useHasBorderPanel`
+- `useHasBorderPanelControls`
+- `useHasColorPanel`
+- `useHasDimensionsPanel`
+- `useHasFiltersPanel`
+- `useHasImageSettingsPanel`
+- `useHasTypographyPanel`
+- `useSettingsForBlockElement`
+- `ExperimentalBlockCanvas`: version of public `BlockCanvas` that has several extra props: `contentRef`, `shouldIframe`, `iframeProps`.
+- `ExperimentalBlockEditorProvider`: version of public `BlockEditorProvider` that filters out several private/experimental settings. See also `__experimentalUpdateSettings`.
+- `getDuotoneFilter`
+- `getRichTextValues`
+- `PrivateQuickInserter`
+- `extractWords`
+- `getNormalizedSearchTerms`
+- `normalizeString`
+- `PrivateListView`
+- `ResizableBoxPopover`
+- `BlockInfo`
+- `useHasBlockToolbar`
+- `cleanEmptyObject`
+- `BlockQuickNavigation`
+- `LayoutStyle`
+- `BlockRemovalWarningModal`
+- `useLayoutClasses`
+- `useLayoutStyles`
+- `DimensionsTool`
+- `ResolutionTool`
+- `TabbedSidebar`
+- `TextAlignmentControl`
+- `usesContextKey`
+- `useFlashEditableBlocks`
+- `useZoomOut`
+- `globalStylesDataKey`
+- `globalStylesLinksDataKey`
+- `selectBlockPatternsKey`
+- `requiresWrapperOnCopy`
+- `PrivateRichText`: has an extra prop `readOnly` added in #58916 and #60327 (Feb and Mar 2024).
+- `PrivateInserterLibrary`: has an extra prop `onPatternCategorySelection` added in #62130 (May 2024).
+- `reusableBlocksSelectKey`
+- `PrivateBlockPopover`: has two extra props, `__unstableContentRef` and `__unstablePopoverSlot`.
+- `PrivatePublishDateTimePicker`: version of public `PublishDateTimePicker` that has two extra props: `isCompact` and `showPopoverHeaderActions`.
+- `useSpacingSizes`
+- `useBlockDisplayTitle`
+- `__unstableBlockStyleVariationOverridesWithConfig`
+- `setBackgroundStyleDefaults`
+- `sectionRootClientIdKey`
+- `__unstableCommentIconFill`
+- `__unstableCommentIconToolbarFill`
+
+### `core/block-editor` store
+
+Private actions:
+- `__experimentalUpdateSettings`: version of public `updateSettings` action that filters out some private/experimental settings.
+- `clearBlockRemovalPrompt`
+- `deleteStyleOverride`
+- `ensureDefaultBlock`
+- `expandBlock`
+- `hideBlockInterface`
+- `modifyContentLockBlock`
+- `privateRemoveBlocks`
+- `resetZoomLevel`
+- `setBlockRemovalRules`
+- `setInsertionPoint`
+- `setLastFocus`
+- `setOpenedBlockSettingsMenu`
+- `setStyleOverride`
+- `setZoomLevel`
+- `showBlockInterface`
+- `startDragging`
+- `stopDragging`
+- `stopEditingAsBlocks`
+
+Private selectors:
+- `getAllPatterns`
+- `getBlockRemovalRules`
+- `getBlockSettings`
+- `getBlockStyles`
+- `getBlockWithoutAttributes`
+- `getClosestAllowedInsertionPoint`
+- `getClosestAllowedInsertionPointForPattern`
+- `getContentLockingParent`
+- `getEnabledBlockParents`
+- `getEnabledClientIdsTree`
+- `getExpandedBlock`
+- `getInserterMediaCategories`
+- `getInsertionPoint`
+- `getLastFocus`
+- `getLastInsertedBlocksClientIds`
+- `getOpenedBlockSettingsMenu`
+- `getParentSectionBlock`
+- `getPatternBySlug`
+- `getRegisteredInserterMediaCategories`
+- `getRemovalPromptData`
+- `getReusableBlocks`
+- `getSectionRootClientId`
+- `getStyleOverrides`
+- `getTemporarilyEditingAsBlocks`
+- `getTemporarilyEditingFocusModeToRevert`
+- `getZoomLevel`
+- `hasAllowedPatterns`
+- `isBlockInterfaceHidden`
+- `isBlockSubtreeDisabled`
+- `isDragging`
+- `isResolvingPatterns`
+- `isSectionBlock`
+- `isZoomOut`
+
+## core-data
+
+Private exports:
+- `useEntityRecordsWithPermissions`
+
+### `core` store
+
+Private actions:
+- `receiveRegisteredPostMeta`
+
+Private selectors:
+- `getBlockPatternsForPostType`
+- `getEntityRecordPermissions`
+- `getEntityRecordsPermissions`
+- `getNavigationFallbackId`
+- `getRegisteredPostMeta`
+- `getUndoManager`
+
+## patterns (package created in Aug 2023 and has no public exports, everything is private)
+
+Private exports:
+- `OverridesPanel`
+- `CreatePatternModal`
+- `CreatePatternModalContents`
+- `DuplicatePatternModal`
+- `isOverridableBlock`
+- `hasOverridableBlocks`
+- `useDuplicatePatternProps`
+- `RenamePatternModal`
+- `PatternsMenuItems`
+- `RenamePatternCategoryModal`
+- `PatternOverridesControls`
+- `ResetOverridesControl`
+- `PatternOverridesBlockControls`
+- `useAddPatternCategory`
+- `PATTERN_TYPES`
+- `PATTERN_DEFAULT_CATEGORY`
+- `PATTERN_USER_CATEGORY`
+- `EXCLUDED_PATTERN_SOURCES`
+- `PATTERN_SYNC_TYPES`
+- `PARTIAL_SYNCING_SUPPORTED_BLOCKS`
+
+### `core/patterns` store
+
+Private actions:
+- `convertSyncedPatternToStatic`
+- `createPattern`
+- `createPatternFromFile`
+- `setEditingPattern`
+
+Private selectors:
+- `isEditingPattern`
+
+## block-library
+
+Private exports:
+- `BlockKeyboardShortcuts`
+
+## router (private exports only)
+
+Private exports:
+- `useHistory`
+- `useLocation`
+- `RouterProvider`
+
+## core-commands (private exports only)
+
+Private exports:
+- `useCommands`
+
+## editor
+
+Private exports:
+- `CreateTemplatePartModal`
+- `BackButton`
+- `EntitiesSavedStatesExtensible`
+- `Editor`
+- `EditorContentSlotFill`
+- `GlobalStylesProvider`
+- `mergeBaseAndUserConfigs`
+- `PluginPostExcerpt`
+- `PostCardPanel`
+- `PreferencesModal`
+- `usePostActions`
+- `ToolsMoreMenuGroup`
+- `ViewMoreMenuGroup`
+- `ResizableEditor`
+- `registerCoreBlockBindingsSources`
+- `interfaceStore`
+- `ActionItem`
+- `ComplementaryArea`
+- `ComplementaryAreaMoreMenuItem`
+- `FullscreenMode`
+- `InterfaceSkeleton`
+- `NavigableRegion`
+- `PinnedItems`
+
+### `core/editor` store
+
+Private actions:
+- `createTemplate`
+- `hideBlockTypes`
+- `registerEntityAction`
+- `registerPostTypeActions`
+- `removeTemplates`
+- `revertTemplate`
+- `saveDirtyEntities`
+- `setCurrentTemplateId`
+- `setIsReady`
+- `showBlockTypes`
+- `unregisterEntityAction`
+
+Private selectors:
+- `getEntityActions`
+- `getInserter`
+- `getInserterSidebarToggleRef`
+- `getListViewToggleRef`
+- `getPostBlocksByName`
+- `getPostIcon`
+- `hasPostMetaChanges`
+- `isEntityReady`
+
+## edit-post
+
+### `core/edit-post` store
+
+Private selectors:
+- `getEditedPostTemplateId`
+
+## edit-site
+
+### `core/edit-site` store
+
+Private actions:
+- `registerRoute`
+- `setEditorCanvasContainerView`
+
+Private selectors:
+- `getRoutes`
+- `getEditorCanvasContainerView`
From d988d2817c5971775a2e340afea5a3b53c17ced0 Mon Sep 17 00:00:00 2001
From: Prasad Karmalkar
Date: Fri, 13 Dec 2024 15:58:40 +0530
Subject: [PATCH 05/66] Refactor "Settings" panel of Columns block to use
ToolsPanel instead of PanelBody (#67910)
Co-authored-by: prasadkarmalkar
Co-authored-by: fabiankaegy
---
packages/block-library/src/columns/edit.js | 47 +++++++++++++++++-----
1 file changed, 36 insertions(+), 11 deletions(-)
diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js
index f8cf0297302cc..3d5f298aef835 100644
--- a/packages/block-library/src/columns/edit.js
+++ b/packages/block-library/src/columns/edit.js
@@ -9,9 +9,10 @@ import clsx from 'clsx';
import { __ } from '@wordpress/i18n';
import {
Notice,
- PanelBody,
RangeControl,
ToggleControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import {
@@ -149,9 +150,22 @@ function ColumnInspectorControls( {
}
return (
-
+ {
+ updateColumns( count, minCount );
+ setAttributes( {
+ isStackedOnMobile: true,
+ } );
+ } }
+ >
{ canInsertColumnBlock && (
- <>
+ count }
+ onDeselect={ () => updateColumns( count, minCount ) }
+ >
) }
- >
+
) }
-
+ isShownByDefault
+ hasValue={ () => isStackedOnMobile !== true }
+ onDeselect={ () =>
setAttributes( {
- isStackedOnMobile: ! isStackedOnMobile,
+ isStackedOnMobile: true,
} )
}
- />
-
+ >
+
+ setAttributes( {
+ isStackedOnMobile: ! isStackedOnMobile,
+ } )
+ }
+ />
+
+
);
}
From d90fbad61cf2abd35f242b5d45dad4f0e4116c5a Mon Sep 17 00:00:00 2001
From: Prasad Karmalkar
Date: Fri, 13 Dec 2024 16:01:03 +0530
Subject: [PATCH 06/66] Refactor "Settings" panel of Column block to use
ToolsPanel instead of PanelBody (#67913)
Co-authored-by: prasadkarmalkar
Co-authored-by: fabiankaegy
---
packages/block-library/src/column/edit.js | 40 +++++++++++++++--------
1 file changed, 27 insertions(+), 13 deletions(-)
diff --git a/packages/block-library/src/column/edit.js b/packages/block-library/src/column/edit.js
index a0f3cdcf65393..b88e72e8da699 100644
--- a/packages/block-library/src/column/edit.js
+++ b/packages/block-library/src/column/edit.js
@@ -18,8 +18,9 @@ import {
} from '@wordpress/block-editor';
import {
__experimentalUseCustomUnits as useCustomUnits,
- PanelBody,
__experimentalUnitControl as UnitControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { sprintf, __ } from '@wordpress/i18n';
@@ -30,19 +31,32 @@ function ColumnInspectorControls( { width, setAttributes } ) {
availableUnits: availableUnits || [ '%', 'px', 'em', 'rem', 'vw' ],
} );
return (
-
- {
+ setAttributes( { width: undefined } );
+ } }
+ >
+ width !== undefined }
label={ __( 'Width' ) }
- __unstableInputWidth="calc(50% - 8px)"
- __next40pxDefaultSize
- value={ width || '' }
- onChange={ ( nextWidth ) => {
- nextWidth = 0 > parseFloat( nextWidth ) ? '0' : nextWidth;
- setAttributes( { width: nextWidth } );
- } }
- units={ units }
- />
-
+ onDeselect={ () => setAttributes( { width: undefined } ) }
+ isShownByDefault
+ >
+ {
+ nextWidth =
+ 0 > parseFloat( nextWidth ) ? '0' : nextWidth;
+ setAttributes( { width: nextWidth } );
+ } }
+ units={ units }
+ />
+
+
);
}
From 662455d9a5e40327e58c6f71190f254969f71081 Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Fri, 13 Dec 2024 13:45:33 +0100
Subject: [PATCH 07/66] Make sure the sidebar navigation item focus style is
fully visible. (#67817)
Co-authored-by: afercia
Co-authored-by: oandregal
---
.../src/components/sidebar-navigation-item/style.scss | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss
index ac1cf8b730861..230967c4c7e0e 100644
--- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss
+++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss
@@ -20,6 +20,11 @@
color: $white;
}
+ // Make sure the focus style is drawn on top of the current item background.
+ &:focus-visible {
+ transform: translateZ(0);
+ }
+
.edit-site-sidebar-navigation-item__drilldown-indicator {
fill: $gray-600;
}
From 673f80d43810fe7d596cef0e4d3543677d85798d Mon Sep 17 00:00:00 2001
From: Manzoor Wani
Date: Fri, 13 Dec 2024 04:57:54 -0800
Subject: [PATCH 08/66] Fix dataviews commonjs export (#67962)
Co-authored-by: manzoorwanijk
Co-authored-by: youknowriad
Co-authored-by: anomiex
---
packages/dataviews/CHANGELOG.md | 18 +++++++++++-------
packages/dataviews/package.json | 3 ++-
2 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 0468a277ba292..887c279714ec0 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -2,21 +2,25 @@
## Unreleased
+### Bug Fixes
+
+- Fixed commonjs export ([#67962](https://github.com/WordPress/gutenberg/pull/67962))
+
## 4.10.0 (2024-12-11)
## Breaking Changes
- Support showing or hiding title, media and description fields ([#67477](https://github.com/WordPress/gutenberg/pull/67477)).
-- Unify the `title`, `media` and `description` fields for the different layouts. So instead of the previous `view.layout.mediaField`, `view.layout.primaryField` and `view.layout.columnFields`, all the layouts now support these three fields with the following config ([#67477](https://github.com/WordPress/gutenberg/pull/67477)):
+- Unify the `title`, `media` and `description` fields for the different layouts. So instead of the previous `view.layout.mediaField`, `view.layout.primaryField` and `view.layout.columnFields`, all the layouts now support these three fields with the following config ([#67477](https://github.com/WordPress/gutenberg/pull/67477)):
```js
const view = {
- type: 'table',
- titleField: 'title',
- mediaField: 'media',
- descriptionField: 'description',
- fields: [ 'author', 'date' ],
-}
+ type: 'table',
+ titleField: 'title',
+ mediaField: 'media',
+ descriptionField: 'description',
+ fields: [ 'author', 'date' ],
+};
```
## Internal
diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json
index c307085bbea07..7f6d96745acab 100644
--- a/packages/dataviews/package.json
+++ b/packages/dataviews/package.json
@@ -27,7 +27,8 @@
"exports": {
".": {
"types": "./build-types/index.d.ts",
- "import": "./build-module/index.js"
+ "import": "./build-module/index.js",
+ "default": "./build/index.js"
},
"./wp": {
"types": "./build-types/index.d.ts",
From 750c8e46e2847cbb780278bd3815a4fdf76098a1 Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Fri, 13 Dec 2024 17:03:57 +0400
Subject: [PATCH 09/66] Editor: Remove the 'content-only' check from
'TemplatePartConverterMenuItem' (#67961)
Co-authored-by: Mamaduka
---
.../components/template-part-menu-items/index.js | 13 ++-----------
1 file changed, 2 insertions(+), 11 deletions(-)
diff --git a/packages/editor/src/components/template-part-menu-items/index.js b/packages/editor/src/components/template-part-menu-items/index.js
index 0e126644d4993..52c50f91b3933 100644
--- a/packages/editor/src/components/template-part-menu-items/index.js
+++ b/packages/editor/src/components/template-part-menu-items/index.js
@@ -27,25 +27,16 @@ export default function TemplatePartMenuItems() {
}
function TemplatePartConverterMenuItem( { clientIds, onClose } ) {
- const { isContentOnly, blocks } = useSelect(
+ const { blocks } = useSelect(
( select ) => {
- const { getBlocksByClientId, getBlockEditingMode } =
- select( blockEditorStore );
+ const { getBlocksByClientId } = select( blockEditorStore );
return {
blocks: getBlocksByClientId( clientIds ),
- isContentOnly:
- clientIds.length === 1 &&
- getBlockEditingMode( clientIds[ 0 ] ) === 'contentOnly',
};
},
[ clientIds ]
);
- // Do not show the convert button if the block is in content-only mode.
- if ( isContentOnly ) {
- return null;
- }
-
// Allow converting a single template part to standard blocks.
if ( blocks.length === 1 && blocks[ 0 ]?.name === 'core/template-part' ) {
return (
From 0b1a6b6631ce033f0fa751c2238ba14bf9e3cfce Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Fri, 13 Dec 2024 14:43:22 +0100
Subject: [PATCH 10/66] Improve logic to show entities saved panel description.
(#67971)
* Improve logic to show entities saved panel description.
* Apply CR suggestion
---------
Co-authored-by: afercia
Co-authored-by: Mamaduka
Co-authored-by: t-hamano
---
.../editor/src/components/entities-saved-states/index.js | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js
index ad584b0df7557..200473cccff70 100644
--- a/packages/editor/src/components/entities-saved-states/index.js
+++ b/packages/editor/src/components/entities-saved-states/index.js
@@ -115,6 +115,10 @@ export function EntitiesSavedStatesExtensible( {
'description'
);
+ const selectItemsToSaveDescription = !! dirtyEntityRecords.length
+ ? __( 'Select the items you want to save.' )
+ : undefined;
+
return (
}
)
- : __( 'Select the items you want to save.' ) }
+ : selectItemsToSaveDescription }
From 629123201f2f513717fab82b43231c681382da5b Mon Sep 17 00:00:00 2001
From: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Date: Fri, 13 Dec 2024 22:55:30 +0900
Subject: [PATCH 11/66] Customizer Widgets: Fix inserter button size and
animation (#67880)
Co-authored-by: t-hamano
Co-authored-by: tyxla
Co-authored-by: jameskoster
---
.../src/components/header/style.scss | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/packages/customize-widgets/src/components/header/style.scss b/packages/customize-widgets/src/components/header/style.scss
index 5c3f37a0bf0d4..73789282108af 100644
--- a/packages/customize-widgets/src/components/header/style.scss
+++ b/packages/customize-widgets/src/components/header/style.scss
@@ -33,16 +33,25 @@
border-radius: $radius-small;
color: $white;
padding: 0;
- min-width: $grid-unit-30;
- height: $grid-unit-30;
+ min-width: $grid-unit-40;
+ height: $grid-unit-40;
margin: $grid-unit-15 0 $grid-unit-15 auto;
&::before {
content: none;
}
+ svg {
+ transition: transform cubic-bezier(0.165, 0.84, 0.44, 1) 0.2s;
+ @include reduce-motion("transition");
+ }
+
&.is-pressed {
background: $gray-900;
+
+ svg {
+ transform: rotate(45deg);
+ }
}
}
From 67557edac8a22538ca3ff9cd9594cf821c5153e9 Mon Sep 17 00:00:00 2001
From: Himanshu Pathak
Date: Fri, 13 Dec 2024 19:34:52 +0530
Subject: [PATCH 12/66] Storybook: Add stories for the TextAlignmentControl
component (#67371)
* Storybook: Add stories for the text-alignment-control component
* Storybook: Update TextAlignmentControl story to follow best practices and simplify the structure
* Storybook: Simplify TextAlignmentControl story
* Storybook: Simplify the documentation for TextAlignmentControl story
Co-authored-by: himanshupathak95
Co-authored-by: t-hamano
---
.../stories/index.story.js | 73 ++++++++++++++-----
1 file changed, 55 insertions(+), 18 deletions(-)
diff --git a/packages/block-editor/src/components/text-alignment-control/stories/index.story.js b/packages/block-editor/src/components/text-alignment-control/stories/index.story.js
index 3744f3fa012a7..fd97f9b60e6a9 100644
--- a/packages/block-editor/src/components/text-alignment-control/stories/index.story.js
+++ b/packages/block-editor/src/components/text-alignment-control/stories/index.story.js
@@ -8,32 +8,69 @@ import { useState } from '@wordpress/element';
*/
import TextAlignmentControl from '../';
-export default {
+const meta = {
title: 'BlockEditor/TextAlignmentControl',
component: TextAlignmentControl,
+ parameters: {
+ docs: {
+ canvas: { sourceState: 'shown' },
+ description: {
+ component: 'Control to facilitate text alignment selections.',
+ },
+ },
+ },
argTypes: {
- onChange: { action: 'onChange' },
- className: { control: 'text' },
+ value: {
+ control: { type: null },
+ description: 'Currently selected text alignment value.',
+ table: {
+ type: {
+ summary: 'string',
+ },
+ },
+ },
+ onChange: {
+ action: 'onChange',
+ control: { type: null },
+ description: 'Handles change in text alignment selection.',
+ table: {
+ type: {
+ summary: 'function',
+ },
+ },
+ },
options: {
control: 'check',
+ description: 'Array of text alignment options to display.',
options: [ 'left', 'center', 'right', 'justify' ],
+ table: {
+ type: { summary: 'array' },
+ },
+ },
+ className: {
+ control: 'text',
+ description: 'Class name to add to the control.',
+ table: {
+ type: { summary: 'string' },
+ },
},
- value: { control: false },
},
};
-const Template = ( { onChange, ...args } ) => {
- const [ value, setValue ] = useState();
- return (
- {
- onChange( ...changeArgs );
- setValue( ...changeArgs );
- } }
- value={ value }
- />
- );
-};
+export default meta;
-export const Default = Template.bind( {} );
+export const Default = {
+ render: function Template( { onChange, ...args } ) {
+ const [ value, setValue ] = useState();
+ return (
+ {
+ onChange( ...changeArgs );
+ setValue( ...changeArgs );
+ } }
+ value={ value }
+ />
+ );
+ },
+};
From 3d17c61018b2e5d37755f690e35e7a0e6eadf5ff Mon Sep 17 00:00:00 2001
From: Lena Morita
Date: Fri, 13 Dec 2024 23:05:33 +0900
Subject: [PATCH 13/66] TreeSelect: Deprecate 36px default size (#67855)
* TreeSelect: Deprecate 36px default size
* Fix types
* Auto-generate readme
* Add changelog
* Fixup readme
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: tyxla
---
packages/components/CHANGELOG.md | 1 +
.../components/src/input-control/types.ts | 6 +-
packages/components/src/tree-select/README.md | 171 +++++++++++++++---
.../src/tree-select/docs-manifest.json | 5 +
packages/components/src/tree-select/index.tsx | 12 +-
.../src/tree-select/stories/index.story.tsx | 1 +
packages/components/src/tree-select/types.ts | 9 +-
7 files changed, 172 insertions(+), 33 deletions(-)
create mode 100644 packages/components/src/tree-select/docs-manifest.json
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 49cc196b1f7e6..af71c4104b4d9 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -8,6 +8,7 @@
### Deprecations
+- `TreeSelect`: Deprecate 36px default size ([#67855](https://github.com/WordPress/gutenberg/pull/67855)).
- `SelectControl`: Deprecate 36px default size ([#66898](https://github.com/WordPress/gutenberg/pull/66898)).
- `InputControl`: Deprecate 36px default size ([#66897](https://github.com/WordPress/gutenberg/pull/66897)).
diff --git a/packages/components/src/input-control/types.ts b/packages/components/src/input-control/types.ts
index 99c5b1aea92c3..edb69def61905 100644
--- a/packages/components/src/input-control/types.ts
+++ b/packages/components/src/input-control/types.ts
@@ -136,7 +136,7 @@ export interface InputBaseProps extends BaseProps, FlexProps {
* If you want to apply standard padding in accordance with the size variant, wrap the element in
* the provided `` component.
*
- * @example
+ * ```jsx
* import {
* __experimentalInputControl as InputControl,
* __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
@@ -145,6 +145,7 @@ export interface InputBaseProps extends BaseProps, FlexProps {
* @ }
* />
+ * ```
*/
prefix?: ReactNode;
/**
@@ -154,7 +155,7 @@ export interface InputBaseProps extends BaseProps, FlexProps {
* If you want to apply standard padding in accordance with the size variant, wrap the element in
* the provided `` component.
*
- * @example
+ * ```jsx
* import {
* __experimentalInputControl as InputControl,
* __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
@@ -163,6 +164,7 @@ export interface InputBaseProps extends BaseProps, FlexProps {
* % }
* />
+ * ```
*/
suffix?: ReactNode;
/**
diff --git a/packages/components/src/tree-select/README.md b/packages/components/src/tree-select/README.md
index 3d26488478bd0..493c83bf993b0 100644
--- a/packages/components/src/tree-select/README.md
+++ b/packages/components/src/tree-select/README.md
@@ -1,10 +1,10 @@
# TreeSelect
-TreeSelect component is used to generate select input fields.
+
-## Usage
+See the WordPress Storybook for more detailed, interactive documentation.
-Render a user interface to select the parent page in a hierarchy of pages:
+Generates a hierarchical select input.
```jsx
import { useState } from 'react';
@@ -15,7 +15,8 @@ const MyTreeSelect = () => {
return (
setPage( newPage ) }
@@ -50,51 +51,165 @@ const MyTreeSelect = () => {
);
}
```
-
## Props
-The set of props accepted by the component will be specified below.
-Props not included in this set will be applied to the SelectControl component being used.
+### `__next40pxDefaultSize`
+
+Start opting into the larger default height that will become the default size in a future version.
+
+ - Type: `boolean`
+ - Required: No
+ - Default: `false`
+
+### `__nextHasNoMarginBottom`
+
+Start opting into the new margin-free styles that will become the default in a future version.
+
+ - Type: `boolean`
+ - Required: No
+ - Default: `false`
+
+### `children`
+
+As an alternative to the `options` prop, `optgroup`s and `options` can be
+passed in as `children` for more customizability.
+
+ - Type: `ReactNode`
+ - Required: No
+
+### `disabled`
-### label
+If true, the `input` will be disabled.
+
+ - Type: `boolean`
+ - Required: No
+ - Default: `false`
+
+### `hideLabelFromVision`
+
+If true, the label will only be visible to screen readers.
+
+ - Type: `boolean`
+ - Required: No
+ - Default: `false`
+
+### `help`
+
+Additional description for the control.
+
+Only use for meaningful description or instructions for the control. An element containing the description will be programmatically associated to the BaseControl by the means of an `aria-describedby` attribute.
+
+ - Type: `ReactNode`
+ - Required: No
+
+### `label`
If this property is added, a label will be generated using label property as the content.
-- Type: `String`
-- Required: No
+ - Type: `ReactNode`
+ - Required: No
+
+### `labelPosition`
+
+The position of the label.
-### noOptionLabel
+ - Type: `"top" | "bottom" | "side" | "edge"`
+ - Required: No
+ - Default: `'top'`
+
+### `noOptionLabel`
If this property is added, an option will be added with this label to represent empty selection.
-- Type: `String`
-- Required: No
+ - Type: `string`
+ - Required: No
+
+### `onChange`
+
+A function that receives the value of the new option that is being selected as input.
+
+ - Type: `(value: string, extra?: { event?: ChangeEvent; }) => void`
+ - Required: No
+
+### `options`
+
+An array of option property objects to be rendered,
+each with a `label` and `value` property, as well as any other
+`` attributes.
-### onChange
+ - Type: `readonly ({ label: string; value: string; } & Omit, "label" | "value">)[]`
+ - Required: No
-A function that receives the id of the new node element that is being selected.
+### `prefix`
+
+Renders an element on the left side of the input.
+
+By default, the prefix is aligned with the edge of the input border, with no padding.
+If you want to apply standard padding in accordance with the size variant, wrap the element in
+the provided `` component.
+
+```jsx
+import {
+ __experimentalInputControl as InputControl,
+ __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
+} from '@wordpress/components';
+
+@ }
+/>
+```
-- Type: `function`
-- Required: Yes
+ - Type: `ReactNode`
+ - Required: No
-### selectedId
+### `selectedId`
The id of the currently selected node.
-- Type: `string` | `string[]`
-- Required: No
+ - Type: `string`
+ - Required: No
-### tree
+### `size`
+
+Adjusts the size of the input.
+
+ - Type: `"default" | "small" | "compact" | "__unstable-large"`
+ - Required: No
+ - Default: `'default'`
+
+### `suffix`
+
+Renders an element on the right side of the input.
+
+By default, the suffix is aligned with the edge of the input border, with no padding.
+If you want to apply standard padding in accordance with the size variant, wrap the element in
+the provided `` component.
+
+```jsx
+import {
+ __experimentalInputControl as InputControl,
+ __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
+} from '@wordpress/components';
+
+% }
+/>
+```
+
+ - Type: `ReactNode`
+ - Required: No
+
+### `tree`
An array containing the tree objects with the possible nodes the user can select.
-- Type: `Object[]`
-- Required: No
+ - Type: `Tree[]`
+ - Required: No
-#### __nextHasNoMarginBottom
+### `variant`
-Start opting into the new margin-free styles that will become the default in a future version.
+The style variant of the control.
-- Type: `Boolean`
-- Required: No
-- Default: `false`
+ - Type: `"default" | "minimal"`
+ - Required: No
+ - Default: `'default'`
diff --git a/packages/components/src/tree-select/docs-manifest.json b/packages/components/src/tree-select/docs-manifest.json
new file mode 100644
index 0000000000000..0e74d71d309e1
--- /dev/null
+++ b/packages/components/src/tree-select/docs-manifest.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "../../schemas/docs-manifest.json",
+ "displayName": "TreeSelect",
+ "filePath": "./index.tsx"
+}
diff --git a/packages/components/src/tree-select/index.tsx b/packages/components/src/tree-select/index.tsx
index 075ae1268e3c7..6611657636162 100644
--- a/packages/components/src/tree-select/index.tsx
+++ b/packages/components/src/tree-select/index.tsx
@@ -11,6 +11,7 @@ import { SelectControl } from '../select-control';
import type { TreeSelectProps, Tree, Truthy } from './types';
import { useDeprecated36pxDefaultSizeProp } from '../utils/use-deprecated-props';
import { ContextSystemProvider } from '../context';
+import { maybeWarnDeprecated36pxSize } from '../utils/deprecated-36px-size';
const CONTEXT_VALUE = {
BaseControl: {
@@ -35,11 +36,11 @@ function getSelectOptions(
}
/**
- * TreeSelect component is used to generate select input fields.
+ * Generates a hierarchical select input.
*
* ```jsx
+ * import { useState } from 'react';
* import { TreeSelect } from '@wordpress/components';
- * import { useState } from '@wordpress/element';
*
* const MyTreeSelect = () => {
* const [ page, setPage ] = useState( 'p21' );
@@ -47,6 +48,7 @@ function getSelectOptions(
* return (
* setPage( newPage ) }
@@ -99,6 +101,12 @@ export function TreeSelect( props: TreeSelectProps ) {
].filter( < T, >( option: T ): option is Truthy< T > => !! option );
}, [ noOptionLabel, tree ] );
+ maybeWarnDeprecated36pxSize( {
+ componentName: 'TreeSelect',
+ size: restProps.size,
+ __next40pxDefaultSize: restProps.__next40pxDefaultSize,
+ } );
+
return (
= ( props ) => {
export const Default = TreeSelectWithState.bind( {} );
Default.args = {
__nextHasNoMarginBottom: true,
+ __next40pxDefaultSize: true,
label: 'Label Text',
noOptionLabel: 'No parent page',
help: 'Help text to explain the select control.',
diff --git a/packages/components/src/tree-select/types.ts b/packages/components/src/tree-select/types.ts
index da90ece3a658e..59e8e173fab02 100644
--- a/packages/components/src/tree-select/types.ts
+++ b/packages/components/src/tree-select/types.ts
@@ -16,11 +16,18 @@ export interface Tree {
// `TreeSelect` inherits props from `SelectControl`, but only
// in single selection mode (ie. when the `multiple` prop is not defined).
export interface TreeSelectProps
- extends Omit< SelectControlSingleSelectionProps, 'value' | 'multiple' > {
+ extends Omit<
+ SelectControlSingleSelectionProps,
+ 'value' | 'multiple' | 'onChange'
+ > {
/**
* If this property is added, an option will be added with this label to represent empty selection.
*/
noOptionLabel?: string;
+ /**
+ * A function that receives the value of the new option that is being selected as input.
+ */
+ onChange?: SelectControlSingleSelectionProps[ 'onChange' ];
/**
* An array containing the tree objects with the possible nodes the user can select.
*/
From c8cdff33b275505b2cc39772eab44fea3553100e Mon Sep 17 00:00:00 2001
From: Sukhendu Sekhar Guria
Date: Fri, 13 Dec 2024 20:00:28 +0530
Subject: [PATCH 14/66] Refactor "Settings" panel of Site Title block to use
ToolsPanel instead of PanelBody (#67898)
Co-authored-by: Sukhendu2002
Co-authored-by: fabiankaegy
---
packages/block-library/src/site-title/edit.js | 60 ++++++++++++++-----
1 file changed, 45 insertions(+), 15 deletions(-)
diff --git a/packages/block-library/src/site-title/edit.js b/packages/block-library/src/site-title/edit.js
index 82e3c1d7f7bb4..644629a96fe4e 100644
--- a/packages/block-library/src/site-title/edit.js
+++ b/packages/block-library/src/site-title/edit.js
@@ -17,7 +17,11 @@ import {
useBlockProps,
HeadingLevelDropdown,
} from '@wordpress/block-editor';
-import { ToggleControl, PanelBody } from '@wordpress/components';
+import {
+ ToggleControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
import { createBlock, getDefaultBlockName } from '@wordpress/blocks';
import { decodeEntities } from '@wordpress/html-entities';
@@ -109,26 +113,52 @@ export default function SiteTitleEdit( {
/>
-
- {
+ setAttributes( {
+ isLink: false,
+ linkTarget: '_self',
+ } );
+ } }
+ >
+ isLink !== false }
label={ __( 'Make title link to home' ) }
- onChange={ () => setAttributes( { isLink: ! isLink } ) }
- checked={ isLink }
- />
- { isLink && (
+ onDeselect={ () => setAttributes( { isLink: false } ) }
+ isShownByDefault
+ >
- setAttributes( {
- linkTarget: value ? '_blank' : '_self',
- } )
+ label={ __( 'Make title link to home' ) }
+ onChange={ () =>
+ setAttributes( { isLink: ! isLink } )
}
- checked={ linkTarget === '_blank' }
+ checked={ isLink }
/>
+
+ { isLink && (
+ linkTarget !== '_self' }
+ label={ __( 'Open in new tab' ) }
+ onDeselect={ () =>
+ setAttributes( { linkTarget: '_self' } )
+ }
+ isShownByDefault
+ >
+
+ setAttributes( {
+ linkTarget: value ? '_blank' : '_self',
+ } )
+ }
+ checked={ linkTarget === '_blank' }
+ />
+
) }
-
+
{ siteTitleContent }
>
From 0d7f1e32f369159dec5352d10fdb89f5a9063d60 Mon Sep 17 00:00:00 2001
From: Sukhendu Sekhar Guria
Date: Fri, 13 Dec 2024 20:02:36 +0530
Subject: [PATCH 15/66] Refactor "Settings" panel of Excerpt block to use
ToolsPanel instead of PanelBody (#67908)
Co-authored-by: Sukhendu2002
Co-authored-by: fabiankaegy
---
.../block-library/src/post-excerpt/edit.js | 73 +++++++++++++------
1 file changed, 52 insertions(+), 21 deletions(-)
diff --git a/packages/block-library/src/post-excerpt/edit.js b/packages/block-library/src/post-excerpt/edit.js
index 05aaf543b5919..ad2b6300e79e4 100644
--- a/packages/block-library/src/post-excerpt/edit.js
+++ b/packages/block-library/src/post-excerpt/edit.js
@@ -16,7 +16,12 @@ import {
Warning,
useBlockProps,
} from '@wordpress/block-editor';
-import { PanelBody, ToggleControl, RangeControl } from '@wordpress/components';
+import {
+ ToggleControl,
+ RangeControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
import { __, _x } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
@@ -219,29 +224,55 @@ export default function PostExcerptEditor( {
/>
-
- {
+ setAttributes( {
+ showMoreOnNewLine: true,
+ excerptLength: 55,
+ } );
+ } }
+ >
+ showMoreOnNewLine !== true }
label={ __( 'Show link on new line' ) }
- checked={ showMoreOnNewLine }
- onChange={ ( newShowMoreOnNewLine ) =>
- setAttributes( {
- showMoreOnNewLine: newShowMoreOnNewLine,
- } )
+ onDeselect={ () =>
+ setAttributes( { showMoreOnNewLine: true } )
}
- />
-
+
+ setAttributes( {
+ showMoreOnNewLine: newShowMoreOnNewLine,
+ } )
+ }
+ />
+
+ excerptLength !== 55 }
label={ __( 'Max number of words' ) }
- value={ excerptLength }
- onChange={ ( value ) => {
- setAttributes( { excerptLength: value } );
- } }
- min="10"
- max="100"
- />
-
+ onDeselect={ () =>
+ setAttributes( { excerptLength: 55 } )
+ }
+ isShownByDefault
+ >
+ {
+ setAttributes( { excerptLength: value } );
+ } }
+ min="10"
+ max="100"
+ />
+
+
{ excerptContent }
From d0d1045056c3c2a6c96609ea0694a5d29761ccde Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Fri, 13 Dec 2024 18:33:40 +0400
Subject: [PATCH 16/66] Button: Replace ButtonGroup usage with
ToggleGroupControl (#65346)
Co-authored-by: Mamaduka
Co-authored-by: ciampo
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: t-hamano
Co-authored-by: tyxla
Co-authored-by: andreawetzel
---
packages/block-library/src/button/edit.js | 48 ++++++++------------
test/e2e/specs/editor/blocks/buttons.spec.js | 14 +++---
2 files changed, 28 insertions(+), 34 deletions(-)
diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js
index 9f2a9048af4c0..520da26ef9671 100644
--- a/packages/block-library/src/button/edit.js
+++ b/packages/block-library/src/button/edit.js
@@ -16,13 +16,13 @@ import removeAnchorTag from '../utils/remove-anchor-tag';
import { __ } from '@wordpress/i18n';
import { useEffect, useState, useRef, useMemo } from '@wordpress/element';
import {
- Button,
- ButtonGroup,
TextControl,
ToolbarButton,
Popover,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
import {
AlignmentControl,
@@ -115,46 +115,38 @@ function useEnter( props ) {
}
function WidthPanel( { selectedWidth, setAttributes } ) {
- function handleChange( newWidth ) {
- // Check if we are toggling the width off
- const width = selectedWidth === newWidth ? undefined : newWidth;
-
- // Update attributes.
- setAttributes( { width } );
- }
-
return (
{
- handleChange( undefined );
- } }
+ resetAll={ () => setAttributes( { width: undefined } ) }
>
!! selectedWidth }
- onDeselect={ () => handleChange( undefined ) }
+ onDeselect={ () => setAttributes( { width: undefined } ) }
+ __nextHasNoMarginBottom
>
-
+
+ setAttributes( { width: newWidth } )
+ }
+ isBlock
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ >
{ [ 25, 50, 75, 100 ].map( ( widthValue ) => {
return (
- handleChange( widthValue ) }
- >
- { widthValue }%
-
+ value={ widthValue }
+ label={ `${ widthValue }%` }
+ />
);
} ) }
-
+
);
diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js
index d6b0a0a15c4ea..c7fdc18429e11 100644
--- a/test/e2e/specs/editor/blocks/buttons.spec.js
+++ b/test/e2e/specs/editor/blocks/buttons.spec.js
@@ -263,12 +263,14 @@ test.describe( 'Buttons', () => {
await editor.insertBlock( { name: 'core/buttons' } );
await page.keyboard.type( 'Content' );
await editor.openDocumentSettingsSidebar();
- await page.click(
- `role=region[name="Editor settings"i] >> role=tab[name="Settings"i]`
- );
- await page.click(
- 'role=group[name="Button width"i] >> role=button[name="25%"i]'
- );
+ await page
+ .getByRole( 'region', { name: 'Editor settings' } )
+ .getByRole( 'tab', { name: 'Settings' } )
+ .click();
+ await page
+ .getByRole( 'radiogroup', { name: 'Button width' } )
+ .getByRole( 'radio', { name: '25%' } )
+ .click();
// Check the content.
const content = await editor.getEditedPostContent();
From 2b5da49117f7bca612099ae39e36c0c184bd4e99 Mon Sep 17 00:00:00 2001
From: Mayank Tripathi <70465598+Mayank-Tripathi32@users.noreply.github.com>
Date: Fri, 13 Dec 2024 20:16:02 +0530
Subject: [PATCH 17/66] Refactor "Settings" panel of Details block to use
ToolsPanel instead of PanelBody (#67966)
Co-authored-by: Mayank-Tripathi32
Co-authored-by: fabiankaegy
---
packages/block-library/src/details/edit.js | 44 ++++++++++++++++------
1 file changed, 33 insertions(+), 11 deletions(-)
diff --git a/packages/block-library/src/details/edit.js b/packages/block-library/src/details/edit.js
index 314556ba6d591..14c89b7d0f9f0 100644
--- a/packages/block-library/src/details/edit.js
+++ b/packages/block-library/src/details/edit.js
@@ -9,7 +9,11 @@ import {
InspectorControls,
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
-import { PanelBody, ToggleControl } from '@wordpress/components';
+import {
+ ToggleControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const TEMPLATE = [
@@ -46,18 +50,36 @@ function DetailsEdit( { attributes, setAttributes, clientId } ) {
return (
<>
-
- {
+ setAttributes( {
+ showContent: false,
+ } );
+ } }
+ >
+
+ hasValue={ () => showContent }
+ onDeselect={ () => {
setAttributes( {
- showContent: ! showContent,
- } )
- }
- />
-
+ showContent: false,
+ } );
+ } }
+ >
+
+ setAttributes( {
+ showContent: ! showContent,
+ } )
+ }
+ />
+
+
Date: Fri, 13 Dec 2024 20:23:39 +0530
Subject: [PATCH 18/66] Refactor "Settings" panel of Social Icon block to use
ToolsPanel instead of PanelBody (#67974)
Co-authored-by: Mayank-Tripathi32
Co-authored-by: fabiankaegy
---
.../block-library/src/social-link/edit.js | 24 ++++++++++++++-----
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js
index 91f1e4170b33d..43fb305d52ffa 100644
--- a/packages/block-library/src/social-link/edit.js
+++ b/packages/block-library/src/social-link/edit.js
@@ -22,10 +22,10 @@ import { useState, useRef } from '@wordpress/element';
import {
Button,
Dropdown,
- PanelBody,
- PanelRow,
TextControl,
ToolbarButton,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
__experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
} from '@wordpress/components';
import { useMergeRefs } from '@wordpress/compose';
@@ -195,8 +195,20 @@ const SocialLinkEdit = ( {
) }
-
-
+ {
+ setAttributes( { label: undefined } );
+ } }
+ >
+ !! label }
+ onDeselect={ () => {
+ setAttributes( { label: undefined } );
+ } }
+ >
-
-
+
+
Date: Fri, 13 Dec 2024 20:26:38 +0530
Subject: [PATCH 19/66] Refactor "Settings" panel of Login/Logour block to use
ToolsPanel instead of PanelBody (#67909)
Co-authored-by: Infinite-Null
Co-authored-by: fabiankaegy
---
packages/block-library/src/loginout/edit.js | 70 +++++++++++++++------
1 file changed, 50 insertions(+), 20 deletions(-)
diff --git a/packages/block-library/src/loginout/edit.js b/packages/block-library/src/loginout/edit.js
index b6c2e9cf01304..76d6e98b1ccc3 100644
--- a/packages/block-library/src/loginout/edit.js
+++ b/packages/block-library/src/loginout/edit.js
@@ -1,9 +1,13 @@
/**
* WordPress dependencies
*/
-import { PanelBody, ToggleControl } from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
+import {
+ ToggleControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
export default function LoginOutEdit( { attributes, setAttributes } ) {
const { displayLoginAsForm, redirectToCurrent } = attributes;
@@ -11,28 +15,54 @@ export default function LoginOutEdit( { attributes, setAttributes } ) {
return (
<>
-
- {
+ setAttributes( {
+ displayLoginAsForm: false,
+ redirectToCurrent: true,
+ } );
+ } }
+ >
+
- setAttributes( {
- displayLoginAsForm: ! displayLoginAsForm,
- } )
+ isShownByDefault
+ hasValue={ () => displayLoginAsForm }
+ onDeselect={ () =>
+ setAttributes( { displayLoginAsForm: false } )
}
- />
-
+
+ setAttributes( {
+ displayLoginAsForm: ! displayLoginAsForm,
+ } )
+ }
+ />
+
+
- setAttributes( {
- redirectToCurrent: ! redirectToCurrent,
- } )
+ isShownByDefault
+ hasValue={ () => ! redirectToCurrent }
+ onDeselect={ () =>
+ setAttributes( { redirectToCurrent: true } )
}
- />
-
+ >
+
+ setAttributes( {
+ redirectToCurrent: ! redirectToCurrent,
+ } )
+ }
+ />
+
+
Date: Fri, 13 Dec 2024 20:27:22 +0530
Subject: [PATCH 20/66] Refactor "Settings" panel of Tag Cloud block to use
ToolsPanel instead of PanelBody (#67911)
Co-authored-by: Sukhendu2002
Co-authored-by: fabiankaegy
---
packages/block-library/src/tag-cloud/edit.js | 61 ++++++++++++++++---
.../block-library/src/tag-cloud/editor.scss | 8 ---
2 files changed, 53 insertions(+), 16 deletions(-)
diff --git a/packages/block-library/src/tag-cloud/edit.js b/packages/block-library/src/tag-cloud/edit.js
index eeb568e7a89ef..b41e47faec369 100644
--- a/packages/block-library/src/tag-cloud/edit.js
+++ b/packages/block-library/src/tag-cloud/edit.js
@@ -4,14 +4,14 @@
import {
Flex,
FlexItem,
- PanelBody,
ToggleControl,
SelectControl,
RangeControl,
__experimentalUnitControl as UnitControl,
__experimentalUseCustomUnits as useCustomUnits,
__experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
- __experimentalVStack as VStack,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
Disabled,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
@@ -118,10 +118,25 @@ function TagCloudEdit( { attributes, setAttributes } ) {
const inspectorControls = (
-
- {
+ setAttributes( {
+ taxonomy: 'post_tag',
+ showTagCounts: false,
+ numberOfTags: 45,
+ smallestFontSize: '8pt',
+ largestFontSize: '22pt',
+ } );
+ } }
+ >
+ taxonomy !== 'post_tag' }
+ label={ __( 'Taxonomy' ) }
+ onDeselect={ () =>
+ setAttributes( { taxonomy: 'post_tag' } )
+ }
+ isShownByDefault
>
+
+
+ smallestFontSize !== '8pt' || largestFontSize !== '22pt'
+ }
+ label={ __( 'Font size' ) }
+ onDeselect={ () =>
+ setAttributes( {
+ smallestFontSize: '8pt',
+ largestFontSize: '22pt',
+ } )
+ }
+ isShownByDefault
+ >
+
+ numberOfTags !== 45 }
+ label={ __( 'Number of tags' ) }
+ onDeselect={ () => setAttributes( { numberOfTags: 45 } ) }
+ isShownByDefault
+ >
+
+ showTagCounts !== false }
+ label={ __( 'Show tag counts' ) }
+ onDeselect={ () =>
+ setAttributes( { showTagCounts: false } )
+ }
+ isShownByDefault
+ >
-
-
+
+
);
diff --git a/packages/block-library/src/tag-cloud/editor.scss b/packages/block-library/src/tag-cloud/editor.scss
index e85129e22f1ac..d00a450174f2f 100644
--- a/packages/block-library/src/tag-cloud/editor.scss
+++ b/packages/block-library/src/tag-cloud/editor.scss
@@ -9,11 +9,3 @@
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;
- }
-}
From 75289cf599ec29cda2dfd6d75298252ec52fa0dc Mon Sep 17 00:00:00 2001
From: Mayank Tripathi <70465598+Mayank-Tripathi32@users.noreply.github.com>
Date: Fri, 13 Dec 2024 20:36:17 +0530
Subject: [PATCH 21/66] Refactor "Settings" panel of Social Icons block to use
ToolsPanel instead of PanelBody (#67975)
Co-authored-by: Mayank-Tripathi32
Co-authored-by: fabiankaegy
---
.../block-library/src/social-links/edit.js | 59 ++++++++++++++-----
1 file changed, 44 insertions(+), 15 deletions(-)
diff --git a/packages/block-library/src/social-links/edit.js b/packages/block-library/src/social-links/edit.js
index 068b34a3a70a4..af39219af25a1 100644
--- a/packages/block-library/src/social-links/edit.js
+++ b/packages/block-library/src/social-links/edit.js
@@ -22,9 +22,10 @@ import {
import {
MenuGroup,
MenuItem,
- PanelBody,
ToggleControl,
ToolbarDropdownMenu,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { check } from '@wordpress/icons';
@@ -198,24 +199,52 @@ export function SocialLinksEdit( props ) {
-
- {
+ setAttributes( {
+ openInNewTab: false,
+ showLabels: false,
+ } );
+ } }
+ >
+
- setAttributes( { openInNewTab: ! openInNewTab } )
+ hasValue={ () => !! openInNewTab }
+ onDeselect={ () =>
+ setAttributes( { openInNewTab: false } )
}
- />
-
+
+ setAttributes( {
+ openInNewTab: ! openInNewTab,
+ } )
+ }
+ />
+
+
- setAttributes( { showLabels: ! showLabels } )
+ hasValue={ () => !! showLabels }
+ onDeselect={ () =>
+ setAttributes( { showLabels: false } )
}
- />
-
+ >
+
+ setAttributes( { showLabels: ! showLabels } )
+ }
+ />
+
+
{ colorGradientSettings.hasColorsOrGradients && (
From b371f6e44aba95efb00c9d7f8578e145bc2ae5c5 Mon Sep 17 00:00:00 2001
From: Lena Morita
Date: Sat, 14 Dec 2024 00:10:51 +0900
Subject: [PATCH 22/66] Icons: Deprecate `warning` and rename to
`cautionFilled` (#67895)
* Icons: Deprecate `warning` and rename to `cautionFilled`
* Update changelog
* Update icon
* Update mobile snapshots
* Rename original variable
* Update existing usage in native files
---------
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: jameskoster
Co-authored-by: tyxla
---
.../src/components/audio-player/index.native.js | 4 ++--
.../src/components/contrast-checker/index.native.js | 4 ++--
.../src/audio/test/__snapshots__/edit.native.js.snap | 2 +-
packages/block-library/src/cover/edit.native.js | 7 +++++--
.../src/file/test/__snapshots__/edit.native.js.snap | 2 +-
.../src/components/error-boundary/index.native.js | 4 ++--
packages/icons/CHANGELOG.md | 2 ++
packages/icons/src/icon/stories/index.story.js | 9 ++++++++-
packages/icons/src/icon/stories/keywords.ts | 2 +-
packages/icons/src/index.js | 6 +++++-
packages/icons/src/library/caution-filled.js | 12 ++++++++++++
packages/icons/src/library/warning.js | 12 ------------
12 files changed, 41 insertions(+), 25 deletions(-)
create mode 100644 packages/icons/src/library/caution-filled.js
delete mode 100644 packages/icons/src/library/warning.js
diff --git a/packages/block-editor/src/components/audio-player/index.native.js b/packages/block-editor/src/components/audio-player/index.native.js
index bee31ea5872ef..734226408cb92 100644
--- a/packages/block-editor/src/components/audio-player/index.native.js
+++ b/packages/block-editor/src/components/audio-player/index.native.js
@@ -17,7 +17,7 @@ import { View } from '@wordpress/primitives';
import { Icon } from '@wordpress/components';
import { withPreferredColorScheme } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
-import { audio, warning } from '@wordpress/icons';
+import { audio, cautionFilled } from '@wordpress/icons';
import {
requestImageFailedRetryDialog,
requestImageUploadCancelDialog,
@@ -167,7 +167,7 @@ function Player( {
{ isUploadFailed && (
-
+
{ msg }
);
diff --git a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap
index 4cf28f7063ad3..9cf88d804068a 100644
--- a/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap
+++ b/packages/block-library/src/audio/test/__snapshots__/edit.native.js.snap
@@ -89,7 +89,7 @@ exports[`Audio block renders audio block error state without crashing 1`] = `
diff --git a/packages/block-library/src/cover/edit.native.js b/packages/block-library/src/cover/edit.native.js
index 99324545bf798..7f73ec85a798e 100644
--- a/packages/block-library/src/cover/edit.native.js
+++ b/packages/block-library/src/cover/edit.native.js
@@ -58,7 +58,7 @@ import {
useCallback,
useMemo,
} from '@wordpress/element';
-import { cover as icon, replace, image, warning } from '@wordpress/icons';
+import { cover as icon, replace, image, cautionFilled } from '@wordpress/icons';
import { getProtocol } from '@wordpress/url';
// eslint-disable-next-line no-restricted-imports
import { store as editPostStore } from '@wordpress/edit-post';
@@ -665,7 +665,10 @@ const Cover = ( {
style={ styles.uploadFailedContainer }
>
-
+
) }
diff --git a/packages/block-library/src/file/test/__snapshots__/edit.native.js.snap b/packages/block-library/src/file/test/__snapshots__/edit.native.js.snap
index 5ce876137ade0..0c9d88a207401 100644
--- a/packages/block-library/src/file/test/__snapshots__/edit.native.js.snap
+++ b/packages/block-library/src/file/test/__snapshots__/edit.native.js.snap
@@ -132,7 +132,7 @@ exports[`File block renders file error state without crashing 1`] = `
diff --git a/packages/editor/src/components/error-boundary/index.native.js b/packages/editor/src/components/error-boundary/index.native.js
index 0de048e811445..4c05ceb3fc150 100644
--- a/packages/editor/src/components/error-boundary/index.native.js
+++ b/packages/editor/src/components/error-boundary/index.native.js
@@ -16,7 +16,7 @@ import {
usePreferredColorSchemeStyle,
withPreferredColorScheme,
} from '@wordpress/compose';
-import { warning } from '@wordpress/icons';
+import { cautionFilled } from '@wordpress/icons';
import { Icon } from '@wordpress/components';
/**
@@ -141,7 +141,7 @@ class ErrorBoundary extends Component {
diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md
index d622019f1ee78..952e3164d4507 100644
--- a/packages/icons/CHANGELOG.md
+++ b/packages/icons/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Deprecate `warning` icon and rename to `cautionFilled` ([#67895](https://github.com/WordPress/gutenberg/pull/67895)).
+
## 10.14.0 (2024-12-11)
## 10.13.0 (2024-11-27)
diff --git a/packages/icons/src/icon/stories/index.story.js b/packages/icons/src/icon/stories/index.story.js
index 8cbf65d9f259e..406f986e6ef5d 100644
--- a/packages/icons/src/icon/stories/index.story.js
+++ b/packages/icons/src/icon/stories/index.story.js
@@ -11,7 +11,14 @@ import check from '../../library/check';
import * as icons from '../../';
import keywords from './keywords';
-const { Icon: _Icon, ...availableIcons } = icons;
+const {
+ Icon: _Icon,
+
+ // Deprecated aliases
+ warning: _warning,
+
+ ...availableIcons
+} = icons;
const meta = {
component: Icon,
diff --git a/packages/icons/src/icon/stories/keywords.ts b/packages/icons/src/icon/stories/keywords.ts
index 3fd962e047bc1..4965bc38c3451 100644
--- a/packages/icons/src/icon/stories/keywords.ts
+++ b/packages/icons/src/icon/stories/keywords.ts
@@ -1,5 +1,6 @@
const keywords: Partial< Record< keyof typeof import('../../'), string[] > > = {
cancelCircleFilled: [ 'close' ],
+ cautionFilled: [ 'alert', 'caution', 'warning' ],
create: [ 'add' ],
file: [ 'folder' ],
seen: [ 'show' ],
@@ -7,7 +8,6 @@ const keywords: Partial< Record< keyof typeof import('../../'), string[] > > = {
thumbsUp: [ 'like' ],
trash: [ 'delete' ],
unseen: [ 'hide' ],
- warning: [ 'alert', 'caution' ],
};
export default keywords;
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index 14eaec92b78c4..ab7edf65e496b 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -37,6 +37,11 @@ export { default as caption } from './library/caption';
export { default as capturePhoto } from './library/capture-photo';
export { default as captureVideo } from './library/capture-video';
export { default as category } from './library/category';
+export {
+ /** @deprecated Import `cautionFilled` instead. */
+ default as warning,
+ default as cautionFilled,
+} from './library/caution-filled';
export { default as chartBar } from './library/chart-bar';
export { default as check } from './library/check';
export { default as chevronDown } from './library/chevron-down';
@@ -301,6 +306,5 @@ export { default as update } from './library/update';
export { default as upload } from './library/upload';
export { default as verse } from './library/verse';
export { default as video } from './library/video';
-export { default as warning } from './library/warning';
export { default as widget } from './library/widget';
export { default as wordpress } from './library/wordpress';
diff --git a/packages/icons/src/library/caution-filled.js b/packages/icons/src/library/caution-filled.js
new file mode 100644
index 0000000000000..5e7779db85f86
--- /dev/null
+++ b/packages/icons/src/library/caution-filled.js
@@ -0,0 +1,12 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const cautionFilled = (
+
+
+
+);
+
+export default cautionFilled;
diff --git a/packages/icons/src/library/warning.js b/packages/icons/src/library/warning.js
deleted file mode 100644
index 97086c5c9292b..0000000000000
--- a/packages/icons/src/library/warning.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { SVG, Path } from '@wordpress/primitives';
-
-const warning = (
-
-
-
-);
-
-export default warning;
From 5931dd517d69d3c567c0445a00f8685f26dff6c0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9?= <583546+oandregal@users.noreply.github.com>
Date: Fri, 13 Dec 2024 16:51:27 +0100
Subject: [PATCH 23/66] Pages: scope padding to custom items (#67977)
Co-authored-by: oandregal
Co-authored-by: afercia
Co-authored-by: jameskoster
---
.../components/sidebar-dataviews/custom-dataviews-list.js | 2 +-
.../edit-site/src/components/sidebar-dataviews/style.scss | 5 ++++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js b/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js
index 847029e8d6dcf..467648e814276 100644
--- a/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js
+++ b/packages/edit-site/src/components/sidebar-dataviews/custom-dataviews-list.js
@@ -212,7 +212,7 @@ export default function CustomDataViewsList( { type, activeView, isCustom } ) {
{ __( 'Custom Views' ) }
-
+
{ customDataViews.map( ( customViewRecord ) => {
return (
Date: Fri, 13 Dec 2024 20:26:41 +0400
Subject: [PATCH 24/66] Enhancement : Badge Component (#66555)
* Create badge component
* Imports and Manifest
* Add test and capability for additional props using spread operator
* Enhance componenet furthermore
* Generate README via manifest & Add in ignore list
* Lock Badge
* Convert Storybook from CSF 2 to CSF 3 Format
* Improve the component
* New iteration
* Add new icons: Error and Caution
* Utilize new icons
* Update icons
* decrease icon size
* Address feedback
* Fix SVG formatting
* Fix unit test
* Remove unnecessary type (already included)
* Update readme
* Adjust icon keywords
* Add changelog
---------
Co-authored-by: Vrishabhsk
Co-authored-by: jameskoster
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: rogermattic
Co-authored-by: jasmussen
---
docs/tool/manifest.js | 1 +
packages/components/CHANGELOG.md | 4 ++
packages/components/src/badge/README.md | 22 +++++++
.../components/src/badge/docs-manifest.json | 5 ++
packages/components/src/badge/index.tsx | 66 +++++++++++++++++++
.../src/badge/stories/index.story.tsx | 53 +++++++++++++++
packages/components/src/badge/styles.scss | 38 +++++++++++
packages/components/src/badge/test/index.tsx | 40 +++++++++++
packages/components/src/badge/types.ts | 12 ++++
packages/components/src/private-apis.ts | 2 +
packages/components/src/style.scss | 1 +
packages/icons/CHANGELOG.md | 2 +
packages/icons/src/icon/stories/keywords.ts | 4 +-
packages/icons/src/index.js | 2 +
packages/icons/src/library/caution.js | 16 +++++
packages/icons/src/library/error.js | 16 +++++
packages/icons/src/library/info.js | 8 ++-
17 files changed, 289 insertions(+), 3 deletions(-)
create mode 100644 packages/components/src/badge/README.md
create mode 100644 packages/components/src/badge/docs-manifest.json
create mode 100644 packages/components/src/badge/index.tsx
create mode 100644 packages/components/src/badge/stories/index.story.tsx
create mode 100644 packages/components/src/badge/styles.scss
create mode 100644 packages/components/src/badge/test/index.tsx
create mode 100644 packages/components/src/badge/types.ts
create mode 100644 packages/icons/src/library/caution.js
create mode 100644 packages/icons/src/library/error.js
diff --git a/docs/tool/manifest.js b/docs/tool/manifest.js
index 2004fae84f7cc..569d78bc5bea8 100644
--- a/docs/tool/manifest.js
+++ b/docs/tool/manifest.js
@@ -18,6 +18,7 @@ const componentPaths = glob( 'packages/components/src/*/**/README.md', {
'packages/components/src/menu/README.md',
'packages/components/src/tabs/README.md',
'packages/components/src/custom-select-control-v2/README.md',
+ 'packages/components/src/badge/README.md',
],
} );
const packagePaths = glob( 'packages/*/package.json' )
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index af71c4104b4d9..c58817a420a74 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -16,6 +16,10 @@
- `BoxControl`: Better respect for the `min` prop in the Range Slider ([#67819](https://github.com/WordPress/gutenberg/pull/67819)).
+### Experimental
+
+- Add new `Badge` component ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
+
## 29.0.0 (2024-12-11)
### Breaking Changes
diff --git a/packages/components/src/badge/README.md b/packages/components/src/badge/README.md
new file mode 100644
index 0000000000000..0be531ca6f2df
--- /dev/null
+++ b/packages/components/src/badge/README.md
@@ -0,0 +1,22 @@
+# Badge
+
+
+
+See the WordPress Storybook for more detailed, interactive documentation.
+
+## Props
+
+### `children`
+
+Text to display inside the badge.
+
+ - Type: `string`
+ - Required: Yes
+
+### `intent`
+
+Badge variant.
+
+ - Type: `"default" | "info" | "success" | "warning" | "error"`
+ - Required: No
+ - Default: `default`
diff --git a/packages/components/src/badge/docs-manifest.json b/packages/components/src/badge/docs-manifest.json
new file mode 100644
index 0000000000000..3b70c0ef22843
--- /dev/null
+++ b/packages/components/src/badge/docs-manifest.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "../../schemas/docs-manifest.json",
+ "displayName": "Badge",
+ "filePath": "./index.tsx"
+}
diff --git a/packages/components/src/badge/index.tsx b/packages/components/src/badge/index.tsx
new file mode 100644
index 0000000000000..8a55f3881215f
--- /dev/null
+++ b/packages/components/src/badge/index.tsx
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { info, caution, error, published } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { BadgeProps } from './types';
+import type { WordPressComponentProps } from '../context';
+import Icon from '../icon';
+
+function Badge( {
+ className,
+ intent = 'default',
+ children,
+ ...props
+}: WordPressComponentProps< BadgeProps, 'span', false > ) {
+ /**
+ * Returns an icon based on the badge context.
+ *
+ * @return The corresponding icon for the provided context.
+ */
+ function contextBasedIcon() {
+ switch ( intent ) {
+ case 'info':
+ return info;
+ case 'success':
+ return published;
+ case 'warning':
+ return caution;
+ case 'error':
+ return error;
+ default:
+ return null;
+ }
+ }
+
+ return (
+
+ { intent !== 'default' && (
+
+ ) }
+ { children }
+
+ );
+}
+
+export default Badge;
diff --git a/packages/components/src/badge/stories/index.story.tsx b/packages/components/src/badge/stories/index.story.tsx
new file mode 100644
index 0000000000000..aaa4bfb3c08f6
--- /dev/null
+++ b/packages/components/src/badge/stories/index.story.tsx
@@ -0,0 +1,53 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryObj } from '@storybook/react';
+
+/**
+ * Internal dependencies
+ */
+import Badge from '..';
+
+const meta = {
+ component: Badge,
+ title: 'Components/Containers/Badge',
+ tags: [ 'status-private' ],
+} satisfies Meta< typeof Badge >;
+
+export default meta;
+
+type Story = StoryObj< typeof meta >;
+
+export const Default: Story = {
+ args: {
+ children: 'Code is Poetry',
+ },
+};
+
+export const Info: Story = {
+ args: {
+ ...Default.args,
+ intent: 'info',
+ },
+};
+
+export const Success: Story = {
+ args: {
+ ...Default.args,
+ intent: 'success',
+ },
+};
+
+export const Warning: Story = {
+ args: {
+ ...Default.args,
+ intent: 'warning',
+ },
+};
+
+export const Error: Story = {
+ args: {
+ ...Default.args,
+ intent: 'error',
+ },
+};
diff --git a/packages/components/src/badge/styles.scss b/packages/components/src/badge/styles.scss
new file mode 100644
index 0000000000000..e1e9cd5312d11
--- /dev/null
+++ b/packages/components/src/badge/styles.scss
@@ -0,0 +1,38 @@
+$badge-colors: (
+ "info": #3858e9,
+ "warning": $alert-yellow,
+ "error": $alert-red,
+ "success": $alert-green,
+);
+
+.components-badge {
+ background-color: color-mix(in srgb, $white 90%, var(--base-color));
+ color: color-mix(in srgb, $black 50%, var(--base-color));
+ padding: 0 $grid-unit-10;
+ min-height: $grid-unit-30;
+ border-radius: $radius-small;
+ font-size: $font-size-small;
+ font-weight: 400;
+ flex-shrink: 0;
+ line-height: $font-line-height-small;
+ width: fit-content;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ &:where(.is-default) {
+ background-color: $gray-100;
+ color: $gray-800;
+ }
+
+ &.has-icon {
+ padding-inline-start: $grid-unit-05;
+ }
+
+ // Generate color variants
+ @each $type, $color in $badge-colors {
+ &.is-#{$type} {
+ --base-color: #{$color};
+ }
+ }
+}
diff --git a/packages/components/src/badge/test/index.tsx b/packages/components/src/badge/test/index.tsx
new file mode 100644
index 0000000000000..47c832eb3c830
--- /dev/null
+++ b/packages/components/src/badge/test/index.tsx
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import Badge from '..';
+
+describe( 'Badge', () => {
+ it( 'should render correctly with default props', () => {
+ render( Code is Poetry );
+ const badge = screen.getByText( 'Code is Poetry' );
+ expect( badge ).toBeInTheDocument();
+ expect( badge.tagName ).toBe( 'SPAN' );
+ expect( badge ).toHaveClass( 'components-badge' );
+ } );
+
+ it( 'should render as per its intent and contain an icon', () => {
+ render( Code is Poetry );
+ const badge = screen.getByText( 'Code is Poetry' );
+ expect( badge ).toHaveClass( 'components-badge', 'is-error' );
+ expect( badge ).toHaveClass( 'has-icon' );
+ } );
+
+ it( 'should combine custom className with default class', () => {
+ render( Code is Poetry );
+ const badge = screen.getByText( 'Code is Poetry' );
+ expect( badge ).toHaveClass( 'components-badge' );
+ expect( badge ).toHaveClass( 'custom-class' );
+ } );
+
+ it( 'should pass through additional props', () => {
+ render( Code is Poetry );
+ const badge = screen.getByTestId( 'custom-badge' );
+ expect( badge ).toHaveTextContent( 'Code is Poetry' );
+ expect( badge ).toHaveClass( 'components-badge' );
+ } );
+} );
diff --git a/packages/components/src/badge/types.ts b/packages/components/src/badge/types.ts
new file mode 100644
index 0000000000000..91cd7c39b549b
--- /dev/null
+++ b/packages/components/src/badge/types.ts
@@ -0,0 +1,12 @@
+export type BadgeProps = {
+ /**
+ * Badge variant.
+ *
+ * @default 'default'
+ */
+ intent?: 'default' | 'info' | 'success' | 'warning' | 'error';
+ /**
+ * Text to display inside the badge.
+ */
+ children: string;
+};
diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts
index 2ced100dc576b..f5a9ee90519c2 100644
--- a/packages/components/src/private-apis.ts
+++ b/packages/components/src/private-apis.ts
@@ -8,6 +8,7 @@ import Theme from './theme';
import { Tabs } from './tabs';
import { kebabCase } from './utils/strings';
import { lock } from './lock-unlock';
+import Badge from './badge';
export const privateApis = {};
lock( privateApis, {
@@ -17,4 +18,5 @@ lock( privateApis, {
Theme,
Menu,
kebabCase,
+ Badge,
} );
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index 70317f4a2d0e0..368dec0f5e253 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -10,6 +10,7 @@
// Components
@import "./animate/style.scss";
@import "./autocomplete/style.scss";
+@import "./badge/styles.scss";
@import "./button-group/style.scss";
@import "./button/style.scss";
@import "./checkbox-control/style.scss";
diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md
index 952e3164d4507..64c1a58b549ca 100644
--- a/packages/icons/CHANGELOG.md
+++ b/packages/icons/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Add new `caution` icon ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
+- Add new `error` icon ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
- Deprecate `warning` icon and rename to `cautionFilled` ([#67895](https://github.com/WordPress/gutenberg/pull/67895)).
## 10.14.0 (2024-12-11)
diff --git a/packages/icons/src/icon/stories/keywords.ts b/packages/icons/src/icon/stories/keywords.ts
index 4965bc38c3451..4de5ae9a7dae9 100644
--- a/packages/icons/src/icon/stories/keywords.ts
+++ b/packages/icons/src/icon/stories/keywords.ts
@@ -1,7 +1,9 @@
const keywords: Partial< Record< keyof typeof import('../../'), string[] > > = {
cancelCircleFilled: [ 'close' ],
- cautionFilled: [ 'alert', 'caution', 'warning' ],
+ caution: [ 'alert', 'warning' ],
+ cautionFilled: [ 'alert', 'warning' ],
create: [ 'add' ],
+ error: [ 'alert', 'caution', 'warning' ],
file: [ 'folder' ],
seen: [ 'show' ],
thumbsDown: [ 'dislike' ],
diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js
index ab7edf65e496b..e82b09e5d5afe 100644
--- a/packages/icons/src/index.js
+++ b/packages/icons/src/index.js
@@ -37,6 +37,7 @@ export { default as caption } from './library/caption';
export { default as capturePhoto } from './library/capture-photo';
export { default as captureVideo } from './library/capture-video';
export { default as category } from './library/category';
+export { default as caution } from './library/caution';
export {
/** @deprecated Import `cautionFilled` instead. */
default as warning,
@@ -89,6 +90,7 @@ export { default as download } from './library/download';
export { default as edit } from './library/edit';
export { default as envelope } from './library/envelope';
export { default as external } from './library/external';
+export { default as error } from './library/error';
export { default as file } from './library/file';
export { default as filter } from './library/filter';
export { default as flipHorizontal } from './library/flip-horizontal';
diff --git a/packages/icons/src/library/caution.js b/packages/icons/src/library/caution.js
new file mode 100644
index 0000000000000..f6d23fdfc7edd
--- /dev/null
+++ b/packages/icons/src/library/caution.js
@@ -0,0 +1,16 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const caution = (
+
+
+
+);
+
+export default caution;
diff --git a/packages/icons/src/library/error.js b/packages/icons/src/library/error.js
new file mode 100644
index 0000000000000..2dc2bccbf639c
--- /dev/null
+++ b/packages/icons/src/library/error.js
@@ -0,0 +1,16 @@
+/**
+ * WordPress dependencies
+ */
+import { SVG, Path } from '@wordpress/primitives';
+
+const error = (
+
+
+
+);
+
+export default error;
diff --git a/packages/icons/src/library/info.js b/packages/icons/src/library/info.js
index f3425d9e95041..24d41d798263f 100644
--- a/packages/icons/src/library/info.js
+++ b/packages/icons/src/library/info.js
@@ -4,8 +4,12 @@
import { SVG, Path } from '@wordpress/primitives';
const info = (
-
-
+
+
);
From 9bdbadaa011c28b94b01694cafc80e5a3aaa42b1 Mon Sep 17 00:00:00 2001
From: Sarah Norris <1645628+mikachan@users.noreply.github.com>
Date: Fri, 13 Dec 2024 17:29:42 +0000
Subject: [PATCH 25/66] Pages: Add "Set as posts page" action (#67650)
* Move getItemTitle to its own file
* Add unset homepage action
* Add unset as posts page action
* Add set as posts page action
* Update homepage action tests
* Rename unset options to reset
* Reword posts page reset notice
* Ensure Move to trash is always at end of list
* Update packages/editor/src/components/post-actions/actions.js
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
* Update packages/editor/src/components/post-actions/reset-homepage.js
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
* Remove getItemTitle from utils index.js
* Remove reset actions
* Slight refactor to modal warning in set as posts page action
* Remove use of saveEditedEntityRecord
* Check for currentPostsPage before setting modalwarning
* Add full stop to action success notices
---------
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Co-authored-by: mikachan
Co-authored-by: t-hamano
Co-authored-by: oandregal
Co-authored-by: jameskoster
Co-authored-by: jasmussen
Co-authored-by: paaljoachim
Co-authored-by: youknowriad
---
.../src/components/post-actions/actions.js | 16 +-
.../post-actions/set-as-homepage.js | 38 +----
.../post-actions/set-as-posts-page.js | 158 ++++++++++++++++++
packages/editor/src/utils/get-item-title.js | 25 +++
.../site-editor/homepage-settings.spec.js | 56 ++++++-
5 files changed, 251 insertions(+), 42 deletions(-)
create mode 100644 packages/editor/src/components/post-actions/set-as-posts-page.js
create mode 100644 packages/editor/src/utils/get-item-title.js
diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js
index 808134ea969a1..023b93d31bb51 100644
--- a/packages/editor/src/components/post-actions/actions.js
+++ b/packages/editor/src/components/post-actions/actions.js
@@ -11,6 +11,7 @@ import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
import { useSetAsHomepageAction } from './set-as-homepage';
+import { useSetAsPostsPageAction } from './set-as-posts-page';
export function usePostActions( { postType, onActionPerformed, context } ) {
const { defaultActions } = useSelect(
@@ -43,7 +44,8 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
);
const setAsHomepageAction = useSetAsHomepageAction();
- const shouldShowSetAsHomepageAction =
+ const setAsPostsPageAction = useSetAsPostsPageAction();
+ const shouldShowHomepageActions =
canManageOptions && ! hasFrontPageTemplate;
const { registerPostTypeSchema } = unlock( useDispatch( editorStore ) );
@@ -53,10 +55,15 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
return useMemo( () => {
let actions = [ ...defaultActions ];
- if ( shouldShowSetAsHomepageAction ) {
- actions.push( setAsHomepageAction );
+ if ( shouldShowHomepageActions ) {
+ actions.push( setAsHomepageAction, setAsPostsPageAction );
}
+ // Ensure "Move to trash" is always the last action.
+ actions = actions.sort( ( a, b ) =>
+ b.id === 'move-to-trash' ? -1 : 0
+ );
+
// 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.
@@ -123,6 +130,7 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
defaultActions,
onActionPerformed,
setAsHomepageAction,
- shouldShowSetAsHomepageAction,
+ setAsPostsPageAction,
+ shouldShowHomepageActions,
] );
}
diff --git a/packages/editor/src/components/post-actions/set-as-homepage.js b/packages/editor/src/components/post-actions/set-as-homepage.js
index 0252c84e3ab3f..671906575b412 100644
--- a/packages/editor/src/components/post-actions/set-as-homepage.js
+++ b/packages/editor/src/components/post-actions/set-as-homepage.js
@@ -12,20 +12,11 @@ import {
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
-import { decodeEntities } from '@wordpress/html-entities';
-const getItemTitle = ( item ) => {
- if ( typeof item.title === 'string' ) {
- return decodeEntities( item.title );
- }
- if ( item.title && 'rendered' in item.title ) {
- return decodeEntities( item.title.rendered );
- }
- if ( item.title && 'raw' in item.title ) {
- return decodeEntities( item.title.raw );
- }
- return '';
-};
+/**
+ * Internal dependencies
+ */
+import { getItemTitle } from '../../utils/get-item-title';
const SetAsHomepageModal = ( { items, closeModal } ) => {
const [ item ] = items;
@@ -48,8 +39,7 @@ const SetAsHomepageModal = ( { items, closeModal } ) => {
}
);
- const { saveEditedEntityRecord, saveEntityRecord } =
- useDispatch( coreStore );
+ const { saveEntityRecord } = useDispatch( coreStore );
const { createSuccessNotice, createErrorNotice } =
useDispatch( noticesStore );
@@ -57,29 +47,19 @@ const SetAsHomepageModal = ( { items, closeModal } ) => {
event.preventDefault();
try {
- // Save new home page settings.
- await saveEditedEntityRecord( 'root', 'site', undefined, {
- page_on_front: item.id,
- show_on_front: 'page',
- } );
-
- // This second call to a save function is a workaround for a bug in
- // `saveEditedEntityRecord`. This forces the root site settings to be updated.
- // See https://github.com/WordPress/gutenberg/issues/67161.
await saveEntityRecord( 'root', 'site', {
page_on_front: item.id,
show_on_front: 'page',
} );
- createSuccessNotice( __( 'Homepage updated' ), {
+ createSuccessNotice( __( 'Homepage updated.' ), {
type: 'snackbar',
} );
} catch ( error ) {
- const typedError = error;
const errorMessage =
- typedError.message && typedError.code !== 'unknown_error'
- ? typedError.message
- : __( 'An error occurred while setting the homepage' );
+ error.message && error.code !== 'unknown_error'
+ ? error.message
+ : __( 'An error occurred while setting the homepage.' );
createErrorNotice( errorMessage, { type: 'snackbar' } );
} finally {
closeModal?.();
diff --git a/packages/editor/src/components/post-actions/set-as-posts-page.js b/packages/editor/src/components/post-actions/set-as-posts-page.js
new file mode 100644
index 0000000000000..67c42a7991fe4
--- /dev/null
+++ b/packages/editor/src/components/post-actions/set-as-posts-page.js
@@ -0,0 +1,158 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { useMemo } from '@wordpress/element';
+import {
+ Button,
+ __experimentalText as Text,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as noticesStore } from '@wordpress/notices';
+
+/**
+ * Internal dependencies
+ */
+import { getItemTitle } from '../../utils/get-item-title';
+
+const SetAsPostsPageModal = ( { items, closeModal } ) => {
+ const [ item ] = items;
+ const pageTitle = getItemTitle( item );
+ const { currentPostsPage, isPageForPostsSet, isSaving } = useSelect(
+ ( select ) => {
+ const { getEntityRecord, isSavingEntityRecord } =
+ select( coreStore );
+ const siteSettings = getEntityRecord( 'root', 'site' );
+ const currentPostsPageItem = getEntityRecord(
+ 'postType',
+ 'page',
+ siteSettings?.page_for_posts
+ );
+ return {
+ currentPostsPage: currentPostsPageItem,
+ isPageForPostsSet: siteSettings?.page_for_posts !== 0,
+ isSaving: isSavingEntityRecord( 'root', 'site' ),
+ };
+ }
+ );
+
+ const { saveEntityRecord } = useDispatch( coreStore );
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+
+ async function onSetPageAsPostsPage( event ) {
+ event.preventDefault();
+
+ try {
+ await saveEntityRecord( 'root', 'site', {
+ page_for_posts: item.id,
+ show_on_front: 'page',
+ } );
+
+ createSuccessNotice( __( 'Posts page updated.' ), {
+ type: 'snackbar',
+ } );
+ } catch ( error ) {
+ const errorMessage =
+ error.message && error.code !== 'unknown_error'
+ ? error.message
+ : __( 'An error occurred while setting the posts page.' );
+ createErrorNotice( errorMessage, { type: 'snackbar' } );
+ } finally {
+ closeModal?.();
+ }
+ }
+
+ const modalWarning =
+ isPageForPostsSet && currentPostsPage
+ ? sprintf(
+ // translators: %s: title of the current posts page.
+ __( 'This will replace the current posts page: "%s"' ),
+ getItemTitle( currentPostsPage )
+ )
+ : __( 'This page will show the latest posts.' );
+
+ const modalText = sprintf(
+ // translators: %1$s: title of the page to be set as the posts page, %2$s: posts page replacement warning message.
+ __( 'Set "%1$s" as the posts page? %2$s' ),
+ pageTitle,
+ modalWarning
+ );
+
+ // translators: Button label to confirm setting the specified page as the posts page.
+ const modalButtonLabel = __( 'Set posts page' );
+
+ return (
+
+ );
+};
+
+export const useSetAsPostsPageAction = () => {
+ const { pageOnFront, pageForPosts } = useSelect( ( select ) => {
+ const { getEntityRecord } = select( coreStore );
+ const siteSettings = getEntityRecord( 'root', 'site' );
+ return {
+ pageOnFront: siteSettings?.page_on_front,
+ pageForPosts: siteSettings?.page_for_posts,
+ };
+ } );
+
+ return useMemo(
+ () => ( {
+ id: 'set-as-posts-page',
+ label: __( 'Set as posts page' ),
+ isEligible( post ) {
+ if ( post.status !== 'publish' ) {
+ return false;
+ }
+
+ if ( post.type !== 'page' ) {
+ return false;
+ }
+
+ // Don't show the action if the page is already set as the homepage.
+ if ( pageOnFront === post.id ) {
+ return false;
+ }
+
+ // Don't show the action if the page is already set as the page for posts.
+ if ( pageForPosts === post.id ) {
+ return false;
+ }
+
+ return true;
+ },
+ RenderModal: SetAsPostsPageModal,
+ } ),
+ [ pageForPosts, pageOnFront ]
+ );
+};
diff --git a/packages/editor/src/utils/get-item-title.js b/packages/editor/src/utils/get-item-title.js
new file mode 100644
index 0000000000000..86929c27408a8
--- /dev/null
+++ b/packages/editor/src/utils/get-item-title.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { decodeEntities } from '@wordpress/html-entities';
+
+/**
+ * Helper function to get the title of a post item.
+ * This is duplicated from the `@wordpress/fields` package.
+ * `packages/fields/src/actions/utils.ts`
+ *
+ * @param {Object} item The post item.
+ * @return {string} The title of the item, or an empty string if the title is not found.
+ */
+export function getItemTitle( item ) {
+ if ( typeof item.title === 'string' ) {
+ return decodeEntities( item.title );
+ }
+ if ( item.title && 'rendered' in item.title ) {
+ return decodeEntities( item.title.rendered );
+ }
+ if ( item.title && 'raw' in item.title ) {
+ return decodeEntities( item.title.raw );
+ }
+ return '';
+}
diff --git a/test/e2e/specs/site-editor/homepage-settings.spec.js b/test/e2e/specs/site-editor/homepage-settings.spec.js
index d53130af23ac8..e80e14830364c 100644
--- a/test/e2e/specs/site-editor/homepage-settings.spec.js
+++ b/test/e2e/specs/site-editor/homepage-settings.spec.js
@@ -10,6 +10,14 @@ test.describe( 'Homepage Settings via Editor', () => {
title: 'Homepage',
status: 'publish',
} );
+ await requestUtils.createPage( {
+ title: 'Sample page',
+ status: 'publish',
+ } );
+ await requestUtils.createPage( {
+ title: 'Draft page',
+ status: 'draft',
+ } );
} );
test.beforeEach( async ( { admin, page } ) => {
@@ -28,27 +36,30 @@ test.describe( 'Homepage Settings via Editor', () => {
] );
} );
- test( 'should show "Set as homepage" action on pages with `publish` status', async ( {
+ test( 'should not show "Set as homepage" and "Set as posts page" action on pages with `draft` status', async ( {
page,
} ) => {
- const samplePage = page
+ const draftPage = page
.getByRole( 'gridcell' )
- .getByLabel( 'Homepage' );
- const samplePageRow = page
+ .getByLabel( 'Draft page' );
+ const draftPageRow = page
.getByRole( 'row' )
- .filter( { has: samplePage } );
- await samplePageRow.hover();
- await samplePageRow
+ .filter( { has: draftPage } );
+ await draftPageRow.hover();
+ await draftPageRow
.getByRole( 'button', {
name: 'Actions',
} )
.click();
await expect(
page.getByRole( 'menuitem', { name: 'Set as homepage' } )
- ).toBeVisible();
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+ ).toBeHidden();
} );
- test( 'should not show "Set as homepage" action on current homepage', async ( {
+ test( 'should show correct homepage actions based on current homepage or posts page', async ( {
page,
} ) => {
const samplePage = page
@@ -68,5 +79,32 @@ test.describe( 'Homepage Settings via Editor', () => {
await expect(
page.getByRole( 'menuitem', { name: 'Set as homepage' } )
).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+ ).toBeHidden();
+
+ const samplePageTwo = page
+ .getByRole( 'gridcell' )
+ .getByLabel( 'Sample page' );
+ const samplePageTwoRow = page
+ .getByRole( 'row' )
+ .filter( { has: samplePageTwo } );
+ // eslint-disable-next-line playwright/no-force-option
+ await samplePageTwoRow.click( { force: true } );
+ await samplePageTwoRow
+ .getByRole( 'button', {
+ name: 'Actions',
+ } )
+ .click();
+ await page
+ .getByRole( 'menuitem', { name: 'Set as posts page' } )
+ .click();
+ await page.getByRole( 'button', { name: 'Set posts page' } ).click();
+ await expect(
+ page.getByRole( 'menuitem', { name: 'Set as homepage' } )
+ ).toBeHidden();
+ await expect(
+ page.getByRole( 'menuitem', { name: 'Set as posts page' } )
+ ).toBeHidden();
} );
} );
From 8066995500dd2370dfc3c3b0f5b471506bf3c6ae Mon Sep 17 00:00:00 2001
From: Jarda Snajdr
Date: Fri, 13 Dec 2024 22:23:00 +0100
Subject: [PATCH 26/66] SlotFill: use observableMap everywhere, remove manual
rerendering (#67400)
* SlotFill: use observableMap in base version
* Add changelog entry
---
packages/components/CHANGELOG.md | 4 +
packages/components/src/slot-fill/context.ts | 8 +-
packages/components/src/slot-fill/fill.ts | 25 ++--
.../components/src/slot-fill/provider.tsx | 127 +++++++++---------
packages/components/src/slot-fill/slot.tsx | 63 +++++----
packages/components/src/slot-fill/types.ts | 34 +++--
packages/components/src/slot-fill/use-slot.ts | 27 ----
7 files changed, 143 insertions(+), 145 deletions(-)
delete mode 100644 packages/components/src/slot-fill/use-slot.ts
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index c58817a420a74..fef1769c19b0f 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -20,6 +20,10 @@
- Add new `Badge` component ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
+### Internal
+
+- `SlotFill`: rewrite the non-portal version to use `observableMap` ([#67400](https://github.com/WordPress/gutenberg/pull/67400)).
+
## 29.0.0 (2024-12-11)
### Breaking Changes
diff --git a/packages/components/src/slot-fill/context.ts b/packages/components/src/slot-fill/context.ts
index c4839462fbce0..b1f0718180e9e 100644
--- a/packages/components/src/slot-fill/context.ts
+++ b/packages/components/src/slot-fill/context.ts
@@ -1,20 +1,22 @@
/**
* WordPress dependencies
*/
+import { observableMap } from '@wordpress/compose';
import { createContext } from '@wordpress/element';
+
/**
* Internal dependencies
*/
import type { BaseSlotFillContext } from './types';
const initialValue: BaseSlotFillContext = {
+ slots: observableMap(),
+ fills: observableMap(),
registerSlot: () => {},
unregisterSlot: () => {},
registerFill: () => {},
unregisterFill: () => {},
- getSlot: () => undefined,
- getFills: () => [],
- subscribe: () => () => {},
+ updateFill: () => {},
};
export const SlotFillContext = createContext( initialValue );
diff --git a/packages/components/src/slot-fill/fill.ts b/packages/components/src/slot-fill/fill.ts
index 0a31c8276b3f1..0bd1aec8fa3e0 100644
--- a/packages/components/src/slot-fill/fill.ts
+++ b/packages/components/src/slot-fill/fill.ts
@@ -7,31 +7,26 @@ import { useContext, useLayoutEffect, useRef } from '@wordpress/element';
* Internal dependencies
*/
import SlotFillContext from './context';
-import useSlot from './use-slot';
import type { FillComponentProps } from './types';
export default function Fill( { name, children }: FillComponentProps ) {
const registry = useContext( SlotFillContext );
- const slot = useSlot( name );
+ const instanceRef = useRef( {} );
+ const childrenRef = useRef( children );
- const ref = useRef( {
- name,
- children,
- } );
+ useLayoutEffect( () => {
+ childrenRef.current = children;
+ }, [ children ] );
useLayoutEffect( () => {
- const refValue = ref.current;
- refValue.name = name;
- registry.registerFill( name, refValue );
- return () => registry.unregisterFill( name, refValue );
+ const instance = instanceRef.current;
+ registry.registerFill( name, instance, childrenRef.current );
+ return () => registry.unregisterFill( name, instance );
}, [ registry, name ] );
useLayoutEffect( () => {
- ref.current.children = children;
- if ( slot ) {
- slot.rerender();
- }
- }, [ slot, children ] );
+ registry.updateFill( name, instanceRef.current, childrenRef.current );
+ } );
return null;
}
diff --git a/packages/components/src/slot-fill/provider.tsx b/packages/components/src/slot-fill/provider.tsx
index e2b98e73e1b70..e5319bc7f33e4 100644
--- a/packages/components/src/slot-fill/provider.tsx
+++ b/packages/components/src/slot-fill/provider.tsx
@@ -8,103 +8,102 @@ import { useState } from '@wordpress/element';
*/
import SlotFillContext from './context';
import type {
- FillComponentProps,
+ FillInstance,
+ FillChildren,
+ BaseSlotInstance,
BaseSlotFillContext,
SlotFillProviderProps,
SlotKey,
- Rerenderable,
} from './types';
+import { observableMap } from '@wordpress/compose';
function createSlotRegistry(): BaseSlotFillContext {
- const slots: Record< SlotKey, Rerenderable > = {};
- const fills: Record< SlotKey, FillComponentProps[] > = {};
- let listeners: Array< () => void > = [];
-
- function registerSlot( name: SlotKey, slot: Rerenderable ) {
- const previousSlot = slots[ name ];
- slots[ name ] = slot;
- triggerListeners();
-
- // Sometimes the fills are registered after the initial render of slot
- // But before the registerSlot call, we need to rerender the slot.
- forceUpdateSlot( name );
-
- // If a new instance of a slot is being mounted while another with the
- // same name exists, force its update _after_ the new slot has been
- // assigned into the instance, such that its own rendering of children
- // will be empty (the new Slot will subsume all fills for this name).
- if ( previousSlot ) {
- previousSlot.rerender();
- }
- }
-
- function registerFill( name: SlotKey, instance: FillComponentProps ) {
- fills[ name ] = [ ...( fills[ name ] || [] ), instance ];
- forceUpdateSlot( name );
+ const slots = observableMap< SlotKey, BaseSlotInstance >();
+ const fills = observableMap<
+ SlotKey,
+ { instance: FillInstance; children: FillChildren }[]
+ >();
+
+ function registerSlot( name: SlotKey, instance: BaseSlotInstance ) {
+ slots.set( name, instance );
}
- function unregisterSlot( name: SlotKey, instance: Rerenderable ) {
+ function unregisterSlot( name: SlotKey, instance: BaseSlotInstance ) {
// If a previous instance of a Slot by this name unmounts, do nothing,
// as the slot and its fills should only be removed for the current
// known instance.
- if ( slots[ name ] !== instance ) {
+ if ( slots.get( name ) !== instance ) {
return;
}
- delete slots[ name ];
- triggerListeners();
+ slots.delete( name );
}
- function unregisterFill( name: SlotKey, instance: FillComponentProps ) {
- fills[ name ] =
- fills[ name ]?.filter( ( fill ) => fill !== instance ) ?? [];
- forceUpdateSlot( name );
+ function registerFill(
+ name: SlotKey,
+ instance: FillInstance,
+ children: FillChildren
+ ) {
+ fills.set( name, [
+ ...( fills.get( name ) || [] ),
+ { instance, children },
+ ] );
}
- function getSlot( name: SlotKey ): Rerenderable | undefined {
- return slots[ name ];
+ function unregisterFill( name: SlotKey, instance: FillInstance ) {
+ const fillsForName = fills.get( name );
+ if ( ! fillsForName ) {
+ return;
+ }
+
+ fills.set(
+ name,
+ fillsForName.filter( ( fill ) => fill.instance !== instance )
+ );
}
- function getFills(
+ function updateFill(
name: SlotKey,
- slotInstance: Rerenderable
- ): FillComponentProps[] {
- // Fills should only be returned for the current instance of the slot
- // in which they occupy.
- if ( slots[ name ] !== slotInstance ) {
- return [];
+ instance: FillInstance,
+ children: FillChildren
+ ) {
+ const fillsForName = fills.get( name );
+ if ( ! fillsForName ) {
+ return;
}
- return fills[ name ];
- }
-
- function forceUpdateSlot( name: SlotKey ) {
- const slot = getSlot( name );
- if ( slot ) {
- slot.rerender();
+ const fillForInstance = fillsForName.find(
+ ( f ) => f.instance === instance
+ );
+ if ( ! fillForInstance ) {
+ return;
}
- }
- function triggerListeners() {
- listeners.forEach( ( listener ) => listener() );
- }
-
- function subscribe( listener: () => void ) {
- listeners.push( listener );
+ if ( fillForInstance.children === children ) {
+ return;
+ }
- return () => {
- listeners = listeners.filter( ( l ) => l !== listener );
- };
+ fills.set(
+ name,
+ fillsForName.map( ( f ) => {
+ if ( f.instance === instance ) {
+ // Replace with new record with updated `children`.
+ return { instance, children };
+ }
+
+ return f;
+ } )
+ );
}
return {
+ slots,
+ fills,
registerSlot,
unregisterSlot,
registerFill,
unregisterFill,
- getSlot,
- getFills,
- subscribe,
+ updateFill,
};
}
diff --git a/packages/components/src/slot-fill/slot.tsx b/packages/components/src/slot-fill/slot.tsx
index fe4a741ddbfba..82feaa04199f5 100644
--- a/packages/components/src/slot-fill/slot.tsx
+++ b/packages/components/src/slot-fill/slot.tsx
@@ -6,10 +6,10 @@ import type { ReactElement, ReactNode, Key } from 'react';
/**
* WordPress dependencies
*/
+import { useObservableValue } from '@wordpress/compose';
import {
useContext,
useEffect,
- useReducer,
useRef,
Children,
cloneElement,
@@ -32,41 +32,48 @@ function isFunction( maybeFunc: any ): maybeFunc is Function {
return typeof maybeFunc === 'function';
}
+function addKeysToChildren( children: ReactNode ) {
+ return Children.map( children, ( child, childIndex ) => {
+ if ( ! child || typeof child === 'string' ) {
+ return child;
+ }
+ let childKey: Key = childIndex;
+ if ( typeof child === 'object' && 'key' in child && child?.key ) {
+ childKey = child.key;
+ }
+
+ return cloneElement( child as ReactElement, {
+ key: childKey,
+ } );
+ } );
+}
+
function Slot( props: Omit< SlotComponentProps, 'bubblesVirtually' > ) {
const registry = useContext( SlotFillContext );
- const [ , rerender ] = useReducer( () => [], [] );
- const ref = useRef( { rerender } );
+ const instanceRef = useRef( {} );
const { name, children, fillProps = {} } = props;
useEffect( () => {
- const refValue = ref.current;
- registry.registerSlot( name, refValue );
- return () => registry.unregisterSlot( name, refValue );
+ const instance = instanceRef.current;
+ registry.registerSlot( name, instance );
+ return () => registry.unregisterSlot( name, instance );
}, [ registry, name ] );
- const fills: ReactNode[] = ( registry.getFills( name, ref.current ) ?? [] )
+ let fills = useObservableValue( registry.fills, name ) ?? [];
+ const currentSlot = useObservableValue( registry.slots, name );
+
+ // Fills should only be rendered in the currently registered instance of the slot.
+ if ( currentSlot !== instanceRef.current ) {
+ fills = [];
+ }
+
+ const renderedFills = fills
.map( ( fill ) => {
const fillChildren = isFunction( fill.children )
? fill.children( fillProps )
: fill.children;
- return Children.map( fillChildren, ( child, childIndex ) => {
- if ( ! child || typeof child === 'string' ) {
- return child;
- }
- let childKey: Key = childIndex;
- if (
- typeof child === 'object' &&
- 'key' in child &&
- child?.key
- ) {
- childKey = child.key;
- }
-
- return cloneElement( child as ReactElement, {
- key: childKey,
- } );
- } );
+ return addKeysToChildren( fillChildren );
} )
.filter(
// In some cases fills are rendered only when some conditions apply.
@@ -75,7 +82,13 @@ function Slot( props: Omit< SlotComponentProps, 'bubblesVirtually' > ) {
( element ) => ! isEmptyElement( element )
);
- return <>{ isFunction( children ) ? children( fills ) : fills }>;
+ return (
+ <>
+ { isFunction( children )
+ ? children( renderedFills )
+ : renderedFills }
+ >
+ );
}
export default Slot;
diff --git a/packages/components/src/slot-fill/types.ts b/packages/components/src/slot-fill/types.ts
index 6668057323edd..758f1c8257d54 100644
--- a/packages/components/src/slot-fill/types.ts
+++ b/packages/components/src/slot-fill/types.ts
@@ -84,6 +84,10 @@ export type SlotComponentProps =
style?: never;
} );
+export type FillChildren =
+ | ReactNode
+ | ( ( fillProps: FillProps ) => ReactNode );
+
export type FillComponentProps = {
/**
* The name of the slot to fill into.
@@ -93,7 +97,7 @@ export type FillComponentProps = {
/**
* Children elements or render function.
*/
- children?: ReactNode | ( ( fillProps: FillProps ) => ReactNode );
+ children?: FillChildren;
};
export type SlotFillProviderProps = {
@@ -109,8 +113,8 @@ export type SlotFillProviderProps = {
};
export type SlotRef = RefObject< HTMLElement >;
-export type Rerenderable = { rerender: () => void };
export type FillInstance = {};
+export type BaseSlotInstance = {};
export type SlotFillBubblesVirtuallyContext = {
slots: ObservableMap< SlotKey, { ref: SlotRef; fillProps: FillProps } >;
@@ -128,14 +132,22 @@ export type SlotFillBubblesVirtuallyContext = {
};
export type BaseSlotFillContext = {
- registerSlot: ( name: SlotKey, slot: Rerenderable ) => void;
- unregisterSlot: ( name: SlotKey, slot: Rerenderable ) => void;
- registerFill: ( name: SlotKey, instance: FillComponentProps ) => void;
- unregisterFill: ( name: SlotKey, instance: FillComponentProps ) => void;
- getSlot: ( name: SlotKey ) => Rerenderable | undefined;
- getFills: (
+ slots: ObservableMap< SlotKey, BaseSlotInstance >;
+ fills: ObservableMap<
+ SlotKey,
+ { instance: FillInstance; children: FillChildren }[]
+ >;
+ registerSlot: ( name: SlotKey, slot: BaseSlotInstance ) => void;
+ unregisterSlot: ( name: SlotKey, slot: BaseSlotInstance ) => void;
+ registerFill: (
+ name: SlotKey,
+ instance: FillInstance,
+ children: FillChildren
+ ) => void;
+ unregisterFill: ( name: SlotKey, instance: FillInstance ) => void;
+ updateFill: (
name: SlotKey,
- slotInstance: Rerenderable
- ) => FillComponentProps[];
- subscribe: ( listener: () => void ) => () => void;
+ instance: FillInstance,
+ children: FillChildren
+ ) => void;
};
diff --git a/packages/components/src/slot-fill/use-slot.ts b/packages/components/src/slot-fill/use-slot.ts
deleted file mode 100644
index 4ab419be1ad2b..0000000000000
--- a/packages/components/src/slot-fill/use-slot.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { useContext, useSyncExternalStore } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import SlotFillContext from './context';
-import type { SlotKey } from './types';
-
-/**
- * React hook returning the active slot given a name.
- *
- * @param name Slot name.
- * @return Slot object.
- */
-const useSlot = ( name: SlotKey ) => {
- const { getSlot, subscribe } = useContext( SlotFillContext );
- return useSyncExternalStore(
- subscribe,
- () => getSlot( name ),
- () => getSlot( name )
- );
-};
-
-export default useSlot;
From e9fb12f396fd1d93c4d0f7f1f4ebad5719c769ad Mon Sep 17 00:00:00 2001
From: Eshaan Dabasiya <76681468+im3dabasia@users.noreply.github.com>
Date: Sat, 14 Dec 2024 04:18:33 +0530
Subject: [PATCH 27/66] Refactor "Settings" panel of Page List block to use
ToolsPanel instead of PanelBody (#67903)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
packages/block-library/src/page-list/edit.js | 85 +++++++++++++-------
1 file changed, 54 insertions(+), 31 deletions(-)
diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js
index 31e400b867671..d9fee67968ac0 100644
--- a/packages/block-library/src/page-list/edit.js
+++ b/packages/block-library/src/page-list/edit.js
@@ -17,12 +17,13 @@ import {
Warning,
} from '@wordpress/block-editor';
import {
- PanelBody,
ToolbarButton,
Spinner,
Notice,
ComboboxControl,
Button,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useMemo, useState, useEffect, useCallback } from '@wordpress/element';
@@ -320,38 +321,60 @@ export default function PageListEdit( {
return (
<>
- { pagesTree.length > 0 && (
-
-
- setAttributes( { parentPageID: value ?? 0 } )
+ {
+ setAttributes( { parentPageID: 0 } );
+ } }
+ >
+ { pagesTree.length > 0 && (
+ parentPageID !== 0 }
+ onDeselect={ () =>
+ setAttributes( { parentPageID: 0 } )
}
- help={ __(
- 'Choose a page to show only its subpages.'
- ) }
- />
-
- ) }
- { allowConvertToLinks && (
-
- { convertDescription }
-
- { __( 'Edit' ) }
-
-
- ) }
+
+ setAttributes( {
+ parentPageID: value ?? 0,
+ } )
+ }
+ help={ __(
+ 'Choose a page to show only its subpages.'
+ ) }
+ />
+
+ ) }
+
+ { allowConvertToLinks && (
+
+
+
{ convertDescription }
+
+ { __( 'Edit' ) }
+
+
+
+ ) }
+
{ allowConvertToLinks && (
<>
From 84d26fcb7235f70a6e83dc51a9b64030b541b19b Mon Sep 17 00:00:00 2001
From: Eshaan Dabasiya <76681468+im3dabasia@users.noreply.github.com>
Date: Sat, 14 Dec 2024 04:22:43 +0530
Subject: [PATCH 28/66] Refactor "Settings" panel of Navigation Submenu block
to use ToolsPanel instead of PanelBody (#67969)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
.../src/navigation-submenu/edit.js | 167 ++++++++++++------
1 file changed, 110 insertions(+), 57 deletions(-)
diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js
index c89eadf1cb589..dbdbd23b13b2f 100644
--- a/packages/block-library/src/navigation-submenu/edit.js
+++ b/packages/block-library/src/navigation-submenu/edit.js
@@ -8,11 +8,12 @@ import clsx from 'clsx';
*/
import { useSelect, useDispatch } from '@wordpress/data';
import {
- PanelBody,
TextControl,
TextareaControl,
ToolbarButton,
ToolbarGroup,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { displayShortcut, isKeyboardEvent } from '@wordpress/keycodes';
import { __ } from '@wordpress/i18n';
@@ -382,67 +383,119 @@ export default function NavigationSubmenuEdit( {
{ /* Warning, this duplicated in packages/block-library/src/navigation-link/edit.js */ }
-
- {
- setAttributes( { label: labelValue } );
- } }
+ {
+ setAttributes( {
+ label: '',
+ url: '',
+ description: '',
+ title: '',
+ rel: '',
+ } );
+ } }
+ >
+
- {
- setAttributes( { url: urlValue } );
- } }
+ isShownByDefault
+ hasValue={ () => !! label }
+ onDeselect={ () => setAttributes( { label: '' } ) }
+ >
+ {
+ setAttributes( { label: labelValue } );
+ } }
+ label={ __( 'Text' ) }
+ autoComplete="off"
+ />
+
+
+
- {
- setAttributes( {
- description: descriptionValue,
- } );
- } }
+ isShownByDefault
+ hasValue={ () => !! url }
+ onDeselect={ () => setAttributes( { url: '' } ) }
+ >
+ {
+ setAttributes( { url: urlValue } );
+ } }
+ label={ __( 'Link' ) }
+ autoComplete="off"
+ />
+
+
+
- {
- setAttributes( { title: titleValue } );
- } }
+ isShownByDefault
+ hasValue={ () => !! description }
+ onDeselect={ () =>
+ setAttributes( { description: '' } )
+ }
+ >
+ {
+ setAttributes( {
+ description: descriptionValue,
+ } );
+ } }
+ label={ __( 'Description' ) }
+ help={ __(
+ 'The description will be displayed in the menu if the current theme supports it.'
+ ) }
+ />
+
+
+
- {
- setAttributes( { rel: relValue } );
- } }
+ isShownByDefault
+ hasValue={ () => !! title }
+ onDeselect={ () => setAttributes( { title: '' } ) }
+ >
+ {
+ setAttributes( { title: titleValue } );
+ } }
+ label={ __( 'Title attribute' ) }
+ autoComplete="off"
+ help={ __(
+ 'Additional information to help clarify the purpose of the link.'
+ ) }
+ />
+
+
+
-
+ isShownByDefault
+ hasValue={ () => !! rel }
+ onDeselect={ () => setAttributes( { rel: '' } ) }
+ >
+ {
+ setAttributes( { rel: relValue } );
+ } }
+ label={ __( 'Rel attribute' ) }
+ autoComplete="off"
+ help={ __(
+ 'The relationship of the linked URL as space-separated link types.'
+ ) }
+ />
+
+
{ /* eslint-disable jsx-a11y/anchor-is-valid */ }
From 85912cb17595b028f2a8477c36209b5342cbed0e Mon Sep 17 00:00:00 2001
From: Eshaan Dabasiya <76681468+im3dabasia@users.noreply.github.com>
Date: Sat, 14 Dec 2024 04:24:04 +0530
Subject: [PATCH 29/66] Refactor "Settings" panel of Query Page Numbers block
to use ToolsPanel instead of PanelBody (#67958)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
.../src/query-pagination-numbers/edit.js | 53 ++++++++++++-------
1 file changed, 34 insertions(+), 19 deletions(-)
diff --git a/packages/block-library/src/query-pagination-numbers/edit.js b/packages/block-library/src/query-pagination-numbers/edit.js
index b8d8c160cc874..0497393e2a4ed 100644
--- a/packages/block-library/src/query-pagination-numbers/edit.js
+++ b/packages/block-library/src/query-pagination-numbers/edit.js
@@ -3,7 +3,11 @@
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
-import { PanelBody, RangeControl } from '@wordpress/components';
+import {
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+ RangeControl,
+} from '@wordpress/components';
const createPaginationItem = ( content, Tag = 'a', extraClass = '' ) => (
@@ -46,28 +50,39 @@ export default function QueryPaginationNumbersEdit( {
const paginationNumbers = previewPaginationNumbers(
parseInt( midSize, 10 )
);
+
return (
<>
-
- setAttributes( { midSize: 2 } ) }
+ >
+ {
- setAttributes( {
- midSize: parseInt( value, 10 ),
- } );
- } }
- min={ 0 }
- max={ 5 }
- withInputField={ false }
- />
-
+ hasValue={ () => midSize !== undefined }
+ onDeselect={ () => setAttributes( { midSize: 2 } ) }
+ isShownByDefault
+ >
+ {
+ setAttributes( {
+ midSize: parseInt( value, 10 ),
+ } );
+ } }
+ min={ 0 }
+ max={ 5 }
+ withInputField={ false }
+ />
+
+
{ paginationNumbers }
>
From 183d93a90b9eaf9ebe08ce6ae9df4e12abeb14ae Mon Sep 17 00:00:00 2001
From: Sunil Prajapati <61308756+akasunil@users.noreply.github.com>
Date: Sat, 14 Dec 2024 11:07:11 +0530
Subject: [PATCH 30/66] Featured Image Block: Refactor setting panel (#67456)
* Refactor setting panel of featured image block control
* Fix link target attribute reset
Co-authored-by: akasunil
Co-authored-by: fabiankaegy
Co-authored-by: aaronrobertshaw
---
.../src/post-featured-image/edit.js | 77 ++++++++++++++++---
1 file changed, 67 insertions(+), 10 deletions(-)
diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js
index 95441a5a55cfd..05888c41fecf2 100644
--- a/packages/block-library/src/post-featured-image/edit.js
+++ b/packages/block-library/src/post-featured-image/edit.js
@@ -11,11 +11,12 @@ import { useEntityProp, store as coreStore } from '@wordpress/core-data';
import { useSelect, useDispatch } from '@wordpress/data';
import {
ToggleControl,
- PanelBody,
Placeholder,
Button,
Spinner,
TextControl,
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import {
InspectorControls,
@@ -38,6 +39,7 @@ import { store as noticesStore } from '@wordpress/notices';
import DimensionControls from './dimension-controls';
import OverlayControls from './overlay-controls';
import Overlay from './overlay';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
const ALLOWED_MEDIA_TYPES = [ 'image' ];
@@ -183,6 +185,8 @@ export default function PostFeaturedImageEdit( {
setTemporaryURL();
};
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+
const controls = blockEditingMode === 'default' && (
<>
@@ -201,9 +205,18 @@ export default function PostFeaturedImageEdit( {
/>
-
- {
+ setAttributes( {
+ isLink: false,
+ linkTarget: '_self',
+ rel: '',
+ } );
+ } }
+ dropdownMenuProps={ dropdownMenuProps }
+ >
+ setAttributes( { isLink: ! isLink } ) }
- checked={ isLink }
- />
+ isShownByDefault
+ hasValue={ () => !! isLink }
+ onDeselect={ () =>
+ setAttributes( {
+ isLink: false,
+ } )
+ }
+ >
+
+ setAttributes( { isLink: ! isLink } )
+ }
+ checked={ isLink }
+ />
+
{ isLink && (
- <>
+ '_self' !== linkTarget }
+ onDeselect={ () =>
+ setAttributes( {
+ linkTarget: '_self',
+ } )
+ }
+ >
+
+ ) }
+ { isLink && (
+ !! rel }
+ onDeselect={ () =>
+ setAttributes( {
+ rel: '',
+ } )
+ }
+ >
- >
+
) }
-
+
>
);
From aed83a9f30583f9b4627956c7e3d3b56b910aef1 Mon Sep 17 00:00:00 2001
From: Mayur Prajapati <91679132+mayurprajapatii@users.noreply.github.com>
Date: Sat, 14 Dec 2024 13:14:02 +0530
Subject: [PATCH 31/66] Updated Document URL in Documentation (#67990)
Co-authored-by: mayurprajapatii
Co-authored-by: pateljaymin29
---
docs/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/README.md b/docs/README.md
index 31471a9928b2c..4fd7d16595e13 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -48,7 +48,7 @@ This handbook should be considered the canonical resource for all things related
## Are you in the right place?
-The Block Editor Handbook is designed for those looking to create and develop for the Block Editor. However, it's important to note that there are multiple other handbooks available within the [Developer Resources](http://developer.wordpress.org/) that you may find beneficial:
+The Block Editor Handbook is designed for those looking to create and develop for the Block Editor. However, it's important to note that there are multiple other handbooks available within the [Developer Resources](https://developer.wordpress.org/) that you may find beneficial:
- [Theme Handbook](https://developer.wordpress.org/themes)
- [Plugin Handbook](https://developer.wordpress.org/plugins)
From 6e8588865ed61c990227dfb9d1324a52f4ff39af Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Sat, 14 Dec 2024 13:09:11 +0400
Subject: [PATCH 32/66] Columns: Replace some store selectors with
'getBlockOrder' (#67991)
Co-authored-by: Mamaduka
Co-authored-by: t-hamano
---
packages/block-library/src/columns/edit.js | 18 +++++++-----------
1 file changed, 7 insertions(+), 11 deletions(-)
diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js
index 3d5f298aef835..d79dfe4fc94a4 100644
--- a/packages/block-library/src/columns/edit.js
+++ b/packages/block-library/src/columns/edit.js
@@ -52,19 +52,15 @@ function ColumnInspectorControls( {
} ) {
const { count, canInsertColumnBlock, minCount } = useSelect(
( select ) => {
- const {
- canInsertBlockType,
- canRemoveBlock,
- getBlocks,
- getBlockCount,
- } = select( blockEditorStore );
- const innerBlocks = getBlocks( clientId );
+ const { canInsertBlockType, canRemoveBlock, getBlockOrder } =
+ select( blockEditorStore );
+ const blockOrder = getBlockOrder( clientId );
// Get the indexes of columns for which removal is prevented.
// The highest index will be used to determine the minimum column count.
- const preventRemovalBlockIndexes = innerBlocks.reduce(
- ( acc, block, index ) => {
- if ( ! canRemoveBlock( block.clientId ) ) {
+ const preventRemovalBlockIndexes = blockOrder.reduce(
+ ( acc, blockId, index ) => {
+ if ( ! canRemoveBlock( blockId ) ) {
acc.push( index );
}
return acc;
@@ -73,7 +69,7 @@ function ColumnInspectorControls( {
);
return {
- count: getBlockCount( clientId ),
+ count: blockOrder.length,
canInsertColumnBlock: canInsertBlockType(
'core/column',
clientId
From 737698812d1991396fe49cfc14d36f57eeba59be Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Sun, 15 Dec 2024 11:52:04 +0000
Subject: [PATCH 33/66] Dataviews List layout: do not use grid role on a `ul`
element. (#67849)
Co-authored-by: jorgefilipecosta
Co-authored-by: oandregal
---
.../dataviews/src/dataviews-layouts/list/index.tsx | 4 ++--
.../dataviews/src/dataviews-layouts/list/style.scss | 10 +++++-----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/packages/dataviews/src/dataviews-layouts/list/index.tsx b/packages/dataviews/src/dataviews-layouts/list/index.tsx
index fd6cdff6dbcdc..960865164fd6c 100644
--- a/packages/dataviews/src/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/dataviews-layouts/list/index.tsx
@@ -257,7 +257,7 @@ function ListItem< Item >( {
return (
}
+ render={
}
role="row"
className={ clsx( {
'is-selected': isSelected,
@@ -482,7 +482,7 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) {
return (
}
+ render={
}
className="dataviews-view-list"
role="grid"
activeId={ activeCompositeId }
diff --git a/packages/dataviews/src/dataviews-layouts/list/style.scss b/packages/dataviews/src/dataviews-layouts/list/style.scss
index 82ef269d46964..e892006faecb0 100644
--- a/packages/dataviews/src/dataviews-layouts/list/style.scss
+++ b/packages/dataviews/src/dataviews-layouts/list/style.scss
@@ -1,11 +1,11 @@
-ul.dataviews-view-list {
+div.dataviews-view-list {
list-style-type: none;
}
.dataviews-view-list {
margin: 0 0 auto;
- li {
+ div[role="row"] {
margin: 0;
border-top: 1px solid $gray-100;
@@ -45,7 +45,7 @@ ul.dataviews-view-list {
&.is-selected.is-selected {
border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12);
- & + li {
+ & + div[role="row"] {
border-top: 1px solid rgba(var(--wp-admin-theme-color--rgb), 0.12);
}
}
@@ -69,8 +69,8 @@ ul.dataviews-view-list {
}
- li.is-selected,
- li.is-selected:focus-within {
+ div[role="row"].is-selected,
+ div[role="row"].is-selected:focus-within {
.dataviews-view-list__item-wrapper {
background-color: rgba(var(--wp-admin-theme-color--rgb), 0.04);
color: $gray-900;
From 6893b2d87910bdc2f1c5dab899f0e352cd774c33 Mon Sep 17 00:00:00 2001
From: tellthemachines
Date: Mon, 16 Dec 2024 14:18:48 +1100
Subject: [PATCH 34/66] Try toggle instead of dropdown to show stylebook.
(#67810)
* Try toggle instead of dropdown to show stylebook.
* Use button instead of actual toggle
* Make button icon except if text labels are enabled
---
.../edit-site/src/components/editor/index.js | 8 +---
.../edit-site/src/components/layout/index.js | 13 ++++-
.../sidebar-global-styles-wrapper/index.js | 47 +++++--------------
.../sidebar-global-styles-wrapper/style.scss | 22 +++++++++
4 files changed, 46 insertions(+), 44 deletions(-)
diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js
index c045bafd8a683..ad88ee07e2150 100644
--- a/packages/edit-site/src/components/editor/index.js
+++ b/packages/edit-site/src/components/editor/index.js
@@ -20,7 +20,6 @@ import { privateApis as blockLibraryPrivateApis } from '@wordpress/block-library
import { useCallback, useMemo } from '@wordpress/element';
import { store as noticesStore } from '@wordpress/notices';
import { privateApis as routerPrivateApis } from '@wordpress/router';
-import { store as preferencesStore } from '@wordpress/preferences';
import { decodeEntities } from '@wordpress/html-entities';
import { Icon, arrowUpLeft } from '@wordpress/icons';
import { store as blockEditorStore } from '@wordpress/block-editor';
@@ -130,7 +129,6 @@ export default function EditSiteEditor( { isPostsList = false } ) {
const { postType, postId, context } = entity;
const {
supportsGlobalStyles,
- showIconLabels,
editorCanvasView,
currentPostIsTrashed,
hasSiteIcon,
@@ -138,13 +136,11 @@ export default function EditSiteEditor( { isPostsList = false } ) {
const { getEditorCanvasContainerView } = unlock(
select( editSiteStore )
);
- const { get } = select( preferencesStore );
const { getCurrentTheme, getEntityRecord } = select( coreDataStore );
const siteData = getEntityRecord( 'root', '__unstableBase', undefined );
return {
supportsGlobalStyles: getCurrentTheme()?.is_block_theme,
- showIconLabels: get( 'core', 'showIconLabels' ),
editorCanvasView: getEditorCanvasContainerView(),
currentPostIsTrashed:
select( editorStore ).getCurrentPostAttribute( 'status' ) ===
@@ -267,9 +263,7 @@ export default function EditSiteEditor( { isPostsList = false } ) {
postId={ postWithTemplate ? context.postId : postId }
templateId={ postWithTemplate ? postId : undefined }
settings={ settings }
- className={ clsx( 'edit-site-editor__editor-interface', {
- 'show-icon-labels': showIconLabels,
- } ) }
+ className="edit-site-editor__editor-interface"
styles={ styles }
customSaveButton={
_isPreviewingTheme &&
diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js
index 20162c5272f2e..a5e14f0be8281 100644
--- a/packages/edit-site/src/components/layout/index.js
+++ b/packages/edit-site/src/components/layout/index.js
@@ -32,7 +32,8 @@ import { privateApis as coreCommandsPrivateApis } from '@wordpress/core-commands
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { PluginArea } from '@wordpress/plugins';
import { store as noticesStore } from '@wordpress/notices';
-import { useDispatch } from '@wordpress/data';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { store as preferencesStore } from '@wordpress/preferences';
/**
* Internal dependencies
@@ -70,6 +71,15 @@ function Layout() {
triggerAnimationOnChange: routeKey + '-' + canvas,
} );
+ const { showIconLabels } = useSelect( ( select ) => {
+ return {
+ showIconLabels: select( preferencesStore ).get(
+ 'core',
+ 'showIconLabels'
+ ),
+ };
+ } );
+
const [ backgroundColor ] = useGlobalStyle( 'color.background' );
const [ gradientValue ] = useGlobalStyle( 'color.gradient' );
const previousCanvaMode = usePrevious( canvas );
@@ -93,6 +103,7 @@ function Layout() {
navigateRegionsProps.className,
{
'is-full-canvas': canvas === 'edit',
+ 'show-icon-labels': showIconLabels,
}
) }
>
diff --git a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
index 980f20c49821b..030512a38fab3 100644
--- a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
+++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
@@ -5,11 +5,9 @@ import { __ } from '@wordpress/i18n';
import { useMemo, useState } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { useViewportMatch } from '@wordpress/compose';
-import {
- Button,
- privateApis as componentsPrivateApis,
-} from '@wordpress/components';
+import { Button } from '@wordpress/components';
import { addQueryArgs } from '@wordpress/url';
+import { seen } from '@wordpress/icons';
/**
* Internal dependencies
@@ -21,44 +19,21 @@ import StyleBook from '../style-book';
import { STYLE_BOOK_COLOR_GROUPS } from '../style-book/constants';
const { useLocation, useHistory } = unlock( routerPrivateApis );
-const { Menu } = unlock( componentsPrivateApis );
const GlobalStylesPageActions = ( {
isStyleBookOpened,
setIsStyleBookOpened,
} ) => {
return (
-
- { __( 'Preview' ) }
-
- }
- >
- setIsStyleBookOpened( true ) }
- defaultChecked
- >
- { __( 'Style book' ) }
-
- { __( 'Preview blocks and styles.' ) }
-
-
- setIsStyleBookOpened( false ) }
- >
- { __( 'Site' ) }
-
- { __( 'Preview your site.' ) }
-
-
-
+ {
+ setIsStyleBookOpened( ! isStyleBookOpened );
+ } }
+ size="compact"
+ />
);
};
diff --git a/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss b/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss
index 88aa9ddf0c161..0fa4e158fe7f1 100644
--- a/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss
+++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss
@@ -33,3 +33,25 @@
color: $gray-900;
}
}
+
+.show-icon-labels {
+ .edit-site-styles .edit-site-page-content {
+ .edit-site-page-header__actions {
+ .components-button.has-icon {
+ width: auto;
+ padding: 0 $grid-unit-10;
+
+ // Hide the button icons when labels are set to display...
+ svg {
+ display: none;
+ }
+ // ... and display labels.
+ &::after {
+ content: attr(aria-label);
+ font-size: $helptext-font-size;
+ }
+ }
+
+ }
+ }
+}
From 34d25a8faf8f60faa23bf11c150b4ff48a4d020a Mon Sep 17 00:00:00 2001
From: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Date: Mon, 16 Dec 2024 15:45:39 +0900
Subject: [PATCH 35/66] DateFormatPicker: Improve line breaks in JSDoc and
README (#68006)
Co-authored-by: t-hamano
Co-authored-by: Mamaduka
---
.../components/date-format-picker/README.md | 19 ++++++-------------
.../components/date-format-picker/index.js | 19 ++++---------------
2 files changed, 10 insertions(+), 28 deletions(-)
diff --git a/packages/block-editor/src/components/date-format-picker/README.md b/packages/block-editor/src/components/date-format-picker/README.md
index e057bdc31a168..f6160cb90955b 100644
--- a/packages/block-editor/src/components/date-format-picker/README.md
+++ b/packages/block-editor/src/components/date-format-picker/README.md
@@ -1,17 +1,12 @@
# DateFormatPicker
-The `DateFormatPicker` component renders controls that let the user choose a
-_date format_. That is, how they want their dates to be formatted.
+The `DateFormatPicker` component renders controls that let the user choose a _date format_. That is, how they want their dates to be formatted.
-A user can pick _Default_ to use the default date format (usually set at the
-site level).
+A user can pick _Default_ to use the default date format (usually set at the site level).
-Otherwise, a user may choose a suggested date format or type in their own date
-format by selecting _Custom_.
+Otherwise, a user may choose a suggested date format or type in their own date format by selecting _Custom_.
-All date format strings should be in the format accepted by by the [`dateI18n`
-function in
-`@wordpress/date`](https://github.com/WordPress/gutenberg/tree/trunk/packages/date#datei18n).
+All date format strings should be in the format accepted by by the [`dateI18n` function in `@wordpress/date`](https://github.com/WordPress/gutenberg/tree/trunk/packages/date#datei18n).
## Usage
@@ -43,16 +38,14 @@ The current date format selected by the user. If `null`, _Default_ is selected.
### `defaultFormat`
-The default format string. Used to show to the user what the date will look like
-if _Default_ is selected.
+The default format string. Used to show to the user what the date will look like if _Default_ is selected.
- Type: `string`
- Required: Yes
### `onChange`
-Called when the user makes a selection, or when the user types in a date format.
-`null` indicates that _Default_ is selected.
+Called when the user makes a selection, or when the user types in a date format. `null` indicates that _Default_ is selected.
- Type: `( format: string|null ) => void`
- Required: Yes
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 eb269e03ca5ab..719390a1d6f90 100644
--- a/packages/block-editor/src/components/date-format-picker/index.js
+++ b/packages/block-editor/src/components/date-format-picker/index.js
@@ -29,21 +29,10 @@ if ( exampleDate.getMonth() === 4 ) {
*
* @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/date-format-picker/README.md
*
- * @param {Object} props
- * @param {string|null} props.format The selected date
- * format. If
- * `null`,
- * _Default_ is
- * selected.
- * @param {string} props.defaultFormat The date format that
- * will be used if the
- * user selects
- * 'Default'.
- * @param {( format: string|null ) => void} props.onChange Called when a
- * selection is
- * made. If `null`,
- * _Default_ is
- * selected.
+ * @param {Object} props
+ * @param {string|null} props.format The selected date format. If `null`, _Default_ is selected.
+ * @param {string} props.defaultFormat The date format that will be used if the user selects 'Default'.
+ * @param {Function} props.onChange Called when a selection is made. If `null`, _Default_ is selected.
*/
export default function DateFormatPicker( {
format,
From bc4f6c6632f52dfc455709bd27bda08041e225ec Mon Sep 17 00:00:00 2001
From: Ankit Kumar Shah
Date: Mon, 16 Dec 2024 13:22:32 +0530
Subject: [PATCH 36/66] LoginOut: Add dropdown menu props to ToolsPanel
component (#68009)
Co-authored-by: Infinite-Null
Co-authored-by: fabiankaegy
---
packages/block-library/src/loginout/edit.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/packages/block-library/src/loginout/edit.js b/packages/block-library/src/loginout/edit.js
index 76d6e98b1ccc3..9af634c87371c 100644
--- a/packages/block-library/src/loginout/edit.js
+++ b/packages/block-library/src/loginout/edit.js
@@ -8,9 +8,14 @@ import {
__experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
export default function LoginOutEdit( { attributes, setAttributes } ) {
const { displayLoginAsForm, redirectToCurrent } = attributes;
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
return (
<>
@@ -23,6 +28,7 @@ export default function LoginOutEdit( { attributes, setAttributes } ) {
redirectToCurrent: true,
} );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
Date: Mon, 16 Dec 2024 12:12:45 +0400
Subject: [PATCH 37/66] DOM: Support class wildcard matcher in 'cleanNodeList'
(#67830)
Co-authored-by: Mamaduka
Co-authored-by: ntsekouras
---
packages/block-library/src/image/transforms.js | 2 +-
packages/dom/src/dom/clean-node-list.js | 5 ++++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/packages/block-library/src/image/transforms.js b/packages/block-library/src/image/transforms.js
index 32824c372efdc..0119009b2182c 100644
--- a/packages/block-library/src/image/transforms.js
+++ b/packages/block-library/src/image/transforms.js
@@ -59,7 +59,7 @@ const schema = ( { phrasingContentSchema } ) => ( {
...imageSchema,
a: {
attributes: [ 'href', 'rel', 'target' ],
- classes: [ /[\w-]*/ ],
+ classes: [ '*' ],
children: imageSchema,
},
figcaption: {
diff --git a/packages/dom/src/dom/clean-node-list.js b/packages/dom/src/dom/clean-node-list.js
index bbdff13b470d6..f1b7e1be7264f 100644
--- a/packages/dom/src/dom/clean-node-list.js
+++ b/packages/dom/src/dom/clean-node-list.js
@@ -76,7 +76,10 @@ export default function cleanNodeList( nodeList, doc, schema, inline ) {
// TODO: Explore patching this in jsdom-jscore.
if ( node.classList && node.classList.length ) {
const mattchers = classes.map( ( item ) => {
- if ( typeof item === 'string' ) {
+ if ( item === '*' ) {
+ // Keep all classes.
+ return () => true;
+ } else if ( typeof item === 'string' ) {
return (
/** @type {string} */ className
) => className === item;
From d02331c2d22a636f20818dd311f9cc8a48b9f82d Mon Sep 17 00:00:00 2001
From: Eshaan Dabasiya <76681468+im3dabasia@users.noreply.github.com>
Date: Mon, 16 Dec 2024 14:00:46 +0530
Subject: [PATCH 38/66] Page List Block: Add dropdown menu props to ToolsPanel
component (#68012)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
packages/block-library/src/page-list/edit.js | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js
index d9fee67968ac0..00d96e9ba307b 100644
--- a/packages/block-library/src/page-list/edit.js
+++ b/packages/block-library/src/page-list/edit.js
@@ -38,6 +38,7 @@ import {
convertDescription,
ConvertToLinksModal,
} from './convert-to-links-modal';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
// We only show the edit option when page count is <= MAX_PAGE_COUNT
// Performance of Navigation Links is not good past this value.
@@ -124,6 +125,7 @@ export default function PageListEdit( {
const [ isOpen, setOpen ] = useState( false );
const openModal = useCallback( () => setOpen( true ), [] );
const closeModal = () => setOpen( false );
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
const { records: pages, hasResolved: hasResolvedPages } = useEntityRecords(
'postType',
@@ -326,6 +328,7 @@ export default function PageListEdit( {
resetAll={ () => {
setAttributes( { parentPageID: 0 } );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
{ pagesTree.length > 0 && (
Date: Mon, 16 Dec 2024 14:01:42 +0530
Subject: [PATCH 39/66] Archive: Add dropdown menu props to ToolsPanel
component (#68010)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
packages/block-library/src/archives/edit.js | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/packages/block-library/src/archives/edit.js b/packages/block-library/src/archives/edit.js
index b51bd9a4fe1e6..d4f25da8507f3 100644
--- a/packages/block-library/src/archives/edit.js
+++ b/packages/block-library/src/archives/edit.js
@@ -12,9 +12,16 @@ import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import ServerSideRender from '@wordpress/server-side-render';
+/**
+ * Internal dependencies
+ */
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
+
export default function ArchivesEdit( { attributes, setAttributes } ) {
const { showLabel, showPostCounts, displayAsDropdown, type } = attributes;
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+
return (
<>
@@ -28,6 +35,7 @@ export default function ArchivesEdit( { attributes, setAttributes } ) {
type: 'monthly',
} );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
Date: Mon, 16 Dec 2024 14:14:40 +0530
Subject: [PATCH 40/66] Query Page Numbers: Add dropdown menu props to
ToolsPanel component (#68013)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy < fabiankaegy@git.wordpress.org>
---
.../block-library/src/query-pagination-numbers/edit.js | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/packages/block-library/src/query-pagination-numbers/edit.js b/packages/block-library/src/query-pagination-numbers/edit.js
index 0497393e2a4ed..cf2f92f41791f 100644
--- a/packages/block-library/src/query-pagination-numbers/edit.js
+++ b/packages/block-library/src/query-pagination-numbers/edit.js
@@ -9,6 +9,11 @@ import {
RangeControl,
} from '@wordpress/components';
+/**
+ * Internal dependencies
+ */
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
+
const createPaginationItem = ( content, Tag = 'a', extraClass = '' ) => (
{ content }
@@ -50,6 +55,7 @@ export default function QueryPaginationNumbersEdit( {
const paginationNumbers = previewPaginationNumbers(
parseInt( midSize, 10 )
);
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
return (
<>
@@ -57,6 +63,7 @@ export default function QueryPaginationNumbersEdit( {
setAttributes( { midSize: 2 } ) }
+ dropdownMenuProps={ dropdownMenuProps }
>
Date: Mon, 16 Dec 2024 10:08:59 +0100
Subject: [PATCH 41/66] Add new private `upload-media` package (#66290)
Co-authored-by: ndiego
Co-authored-by: fabiankaegy
Co-authored-by: carolinan
Co-authored-by: Jon Surrell
Co-authored-by: t-hamano
Co-authored-by: sirreal
Co-authored-by: Andrew Serong <14988353+andrewserong@users.noreply.github.com>
Co-authored-by: stokesman
Co-authored-by: andrewserong
Co-authored-by: ramonjd
Co-authored-by: ntsekouras
Co-authored-by: Mamaduka
Co-authored-by: youknowriad
Co-authored-by: Nick Diego
Co-authored-by: Ella <4710635+ellatrix@users.noreply.github.com>
Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com>
Co-authored-by: Mitchell Austin
Co-authored-by: Nik Tsekouras
Co-authored-by: George Mamadashvili
---
bin/check-licenses.mjs | 2 +-
package-lock.json | 60 +++
.../src/components/provider/index.js | 85 +++-
.../provider/use-media-upload-settings.js | 25 ++
.../block-editor/src/store/private-actions.js | 1 +
packages/editor/README.md | 1 +
.../provider/use-block-editor-settings.js | 3 +
.../editor/src/utils/media-sideload/index.js | 13 +
.../src/utils/media-sideload/index.native.js | 1 +
.../editor/src/utils/media-upload/index.js | 5 +-
packages/media-utils/src/utils/types.ts | 1 -
.../media-utils/src/utils/upload-media.ts | 27 +-
packages/private-apis/src/implementation.ts | 1 +
packages/upload-media/CHANGELOG.md | 5 +
packages/upload-media/README.md | 136 ++++++
packages/upload-media/package.json | 45 ++
.../src/components/provider/index.tsx | 25 ++
.../provider/with-registry-provider.tsx | 59 +++
.../upload-media/src/get-mime-types-array.ts | 29 ++
packages/upload-media/src/image-file.ts | 38 ++
packages/upload-media/src/index.ts | 11 +
packages/upload-media/src/lock-unlock.ts | 10 +
packages/upload-media/src/store/actions.ts | 183 ++++++++
packages/upload-media/src/store/constants.ts | 1 +
packages/upload-media/src/store/index.ts | 43 ++
.../upload-media/src/store/private-actions.ts | 407 ++++++++++++++++++
.../src/store/private-selectors.ts | 113 +++++
packages/upload-media/src/store/reducer.ts | 195 +++++++++
packages/upload-media/src/store/selectors.ts | 67 +++
.../upload-media/src/store/test/actions.ts | 112 +++++
.../upload-media/src/store/test/reducer.ts | 279 ++++++++++++
.../upload-media/src/store/test/selectors.ts | 105 +++++
packages/upload-media/src/store/types.ts | 172 ++++++++
packages/upload-media/src/stub-file.ts | 5 +
.../src/test/get-file-basename.ts | 15 +
.../src/test/get-file-extension.ts | 15 +
.../src/test/get-file-name-from-url.ts | 14 +
.../src/test/get-mime-types-array.ts | 47 ++
packages/upload-media/src/test/image-file.ts | 15 +
.../upload-media/src/test/upload-error.ts | 24 ++
.../src/test/validate-file-size.ts | 70 +++
.../src/test/validate-mime-type-for-user.ts | 37 ++
.../src/test/validate-mime-type.ts | 57 +++
packages/upload-media/src/upload-error.ts | 26 ++
packages/upload-media/src/utils.ts | 90 ++++
.../upload-media/src/validate-file-size.ts | 44 ++
.../src/validate-mime-type-for-user.ts | 46 ++
.../upload-media/src/validate-mime-type.ts | 43 ++
packages/upload-media/tsconfig.json | 20 +
tsconfig.json | 1 +
50 files changed, 2813 insertions(+), 16 deletions(-)
create mode 100644 packages/block-editor/src/components/provider/use-media-upload-settings.js
create mode 100644 packages/editor/src/utils/media-sideload/index.js
create mode 100644 packages/editor/src/utils/media-sideload/index.native.js
create mode 100644 packages/upload-media/CHANGELOG.md
create mode 100644 packages/upload-media/README.md
create mode 100644 packages/upload-media/package.json
create mode 100644 packages/upload-media/src/components/provider/index.tsx
create mode 100644 packages/upload-media/src/components/provider/with-registry-provider.tsx
create mode 100644 packages/upload-media/src/get-mime-types-array.ts
create mode 100644 packages/upload-media/src/image-file.ts
create mode 100644 packages/upload-media/src/index.ts
create mode 100644 packages/upload-media/src/lock-unlock.ts
create mode 100644 packages/upload-media/src/store/actions.ts
create mode 100644 packages/upload-media/src/store/constants.ts
create mode 100644 packages/upload-media/src/store/index.ts
create mode 100644 packages/upload-media/src/store/private-actions.ts
create mode 100644 packages/upload-media/src/store/private-selectors.ts
create mode 100644 packages/upload-media/src/store/reducer.ts
create mode 100644 packages/upload-media/src/store/selectors.ts
create mode 100644 packages/upload-media/src/store/test/actions.ts
create mode 100644 packages/upload-media/src/store/test/reducer.ts
create mode 100644 packages/upload-media/src/store/test/selectors.ts
create mode 100644 packages/upload-media/src/store/types.ts
create mode 100644 packages/upload-media/src/stub-file.ts
create mode 100644 packages/upload-media/src/test/get-file-basename.ts
create mode 100644 packages/upload-media/src/test/get-file-extension.ts
create mode 100644 packages/upload-media/src/test/get-file-name-from-url.ts
create mode 100644 packages/upload-media/src/test/get-mime-types-array.ts
create mode 100644 packages/upload-media/src/test/image-file.ts
create mode 100644 packages/upload-media/src/test/upload-error.ts
create mode 100644 packages/upload-media/src/test/validate-file-size.ts
create mode 100644 packages/upload-media/src/test/validate-mime-type-for-user.ts
create mode 100644 packages/upload-media/src/test/validate-mime-type.ts
create mode 100644 packages/upload-media/src/upload-error.ts
create mode 100644 packages/upload-media/src/utils.ts
create mode 100644 packages/upload-media/src/validate-file-size.ts
create mode 100644 packages/upload-media/src/validate-mime-type-for-user.ts
create mode 100644 packages/upload-media/src/validate-mime-type.ts
create mode 100644 packages/upload-media/tsconfig.json
diff --git a/bin/check-licenses.mjs b/bin/check-licenses.mjs
index 458590e696a9f..b453ebd84cd3a 100755
--- a/bin/check-licenses.mjs
+++ b/bin/check-licenses.mjs
@@ -10,7 +10,7 @@ import { spawnSync } from 'node:child_process';
*/
import { checkDepsInTree } from '../packages/scripts/utils/license.js';
-const ignored = [ '@ampproject/remapping' ];
+const ignored = [ '@ampproject/remapping', 'webpack' ];
/*
* `wp-scripts check-licenses` uses prod and dev dependencies of the package to scan for dependencies. With npm workspaces, workspace packages (the @wordpress/* packages) are not listed in the main package json and this approach does not work.
diff --git a/package-lock.json b/package-lock.json
index 32ff2db498651..e2063a35c7d0a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11071,6 +11071,12 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/@remote-ui/rpc": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@remote-ui/rpc/-/rpc-1.4.5.tgz",
+ "integrity": "sha512-Cr+06niG/vmE4A9YsmaKngRuuVSWKMY42NMwtZfy+gctRWGu6Wj9BWuMJg5CEp+JTkRBPToqT5rqnrg1G/Wvow==",
+ "license": "MIT"
+ },
"node_modules/@samverschueren/stream-to-observable": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz",
@@ -11170,6 +11176,34 @@
"node": ">=8"
}
},
+ "node_modules/@shopify/web-worker": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/@shopify/web-worker/-/web-worker-6.4.0.tgz",
+ "integrity": "sha512-RvY1mgRyAqawFiYBvsBkek2pVK4GVpV9mmhWFCZXwx01usxXd2HMhKNTFeRYhSp29uoUcfBlKZAwCwQzt826tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@remote-ui/rpc": "^1.2.5"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "webpack": "^5.38.0",
+ "webpack-virtual-modules": "^0.4.3 || ^0.5.0 || ^0.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ },
+ "webpack-virtual-modules": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -15823,6 +15857,10 @@
"resolved": "packages/undo-manager",
"link": true
},
+ "node_modules/@wordpress/upload-media": {
+ "resolved": "packages/upload-media",
+ "link": true
+ },
"node_modules/@wordpress/url": {
"resolved": "packages/url",
"link": true
@@ -52562,6 +52600,28 @@
"npm": ">=8.19.2"
}
},
+ "packages/upload-media": {
+ "name": "@wordpress/upload-media",
+ "version": "1.0.0-prerelease",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@shopify/web-worker": "^6.4.0",
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/blob": "file:../blob",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/preferences": "file:../preferences",
+ "@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/url": "file:../url",
+ "uuid": "^9.0.1"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ }
+ },
"packages/url": {
"name": "@wordpress/url",
"version": "4.14.0",
diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js
index abbb122ae3a0e..97aa0b9521687 100644
--- a/packages/block-editor/src/components/provider/index.js
+++ b/packages/block-editor/src/components/provider/index.js
@@ -2,8 +2,13 @@
* WordPress dependencies
*/
import { useDispatch } from '@wordpress/data';
-import { useEffect } from '@wordpress/element';
+import { useEffect, useMemo } from '@wordpress/element';
import { SlotFillProvider } from '@wordpress/components';
+//eslint-disable-next-line import/no-extraneous-dependencies -- Experimental package, not published.
+import {
+ MediaUploadProvider,
+ store as uploadStore,
+} from '@wordpress/upload-media';
/**
* Internal dependencies
@@ -14,12 +19,71 @@ import { store as blockEditorStore } from '../../store';
import { BlockRefsProvider } from './block-refs-provider';
import { unlock } from '../../lock-unlock';
import KeyboardShortcuts from '../keyboard-shortcuts';
+import useMediaUploadSettings from './use-media-upload-settings';
/** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */
+const noop = () => {};
+
+/**
+ * Upload a media file when the file upload button is activated
+ * or when adding a file to the editor via drag & drop.
+ *
+ * @param {WPDataRegistry} registry
+ * @param {Object} $3 Parameters object passed to the function.
+ * @param {Array} $3.allowedTypes Array with the types of media that can be uploaded, if unset all types are allowed.
+ * @param {Object} $3.additionalData Additional data to include in the request.
+ * @param {Array} $3.filesList List of files.
+ * @param {Function} $3.onError Function called when an error happens.
+ * @param {Function} $3.onFileChange Function called each time a file or a temporary representation of the file is available.
+ * @param {Function} $3.onSuccess Function called once a file has completely finished uploading, including thumbnails.
+ * @param {Function} $3.onBatchSuccess Function called once all files in a group have completely finished uploading, including thumbnails.
+ */
+function mediaUpload(
+ registry,
+ {
+ allowedTypes,
+ additionalData = {},
+ filesList,
+ onError = noop,
+ onFileChange,
+ onSuccess,
+ onBatchSuccess,
+ }
+) {
+ void registry.dispatch( uploadStore ).addItems( {
+ files: filesList,
+ onChange: onFileChange,
+ onSuccess,
+ onBatchSuccess,
+ onError: ( { message } ) => onError( message ),
+ additionalData,
+ allowedTypes,
+ } );
+}
+
export const ExperimentalBlockEditorProvider = withRegistryProvider(
( props ) => {
- const { children, settings, stripExperimentalSettings = false } = props;
+ const {
+ settings: _settings,
+ registry,
+ stripExperimentalSettings = false,
+ } = props;
+
+ const mediaUploadSettings = useMediaUploadSettings( _settings );
+
+ let settings = _settings;
+
+ if ( window.__experimentalMediaProcessing && _settings.mediaUpload ) {
+ // Create a new variable so that the original props.settings.mediaUpload is not modified.
+ settings = useMemo(
+ () => ( {
+ ..._settings,
+ mediaUpload: mediaUpload.bind( null, registry ),
+ } ),
+ [ _settings, registry ]
+ );
+ }
const { __experimentalUpdateSettings } = unlock(
useDispatch( blockEditorStore )
@@ -44,12 +108,25 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider(
// Syncs the entity provider with changes in the block-editor store.
useBlockSync( props );
- return (
+ const children = (
{ ! settings?.isPreviewMode && }
- { children }
+ { props.children }
);
+
+ if ( window.__experimentalMediaProcessing ) {
+ return (
+
+ { children }
+
+ );
+ }
+
+ return children;
}
);
diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js
new file mode 100644
index 0000000000000..486066c7aa730
--- /dev/null
+++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { useMemo } from '@wordpress/element';
+
+/**
+ * React hook used to compute the media upload settings to use in the post editor.
+ *
+ * @param {Object} settings Media upload settings prop.
+ *
+ * @return {Object} Media upload settings.
+ */
+function useMediaUploadSettings( settings ) {
+ return useMemo(
+ () => ( {
+ mediaUpload: settings.mediaUpload,
+ mediaSideload: settings.mediaSideload,
+ maxUploadFileSize: settings.maxUploadFileSize,
+ allowedMimeTypes: settings.allowedMimeTypes,
+ } ),
+ [ settings ]
+ );
+}
+
+export default useMediaUploadSettings;
diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js
index e79833e0a73da..f085eb2807c6f 100644
--- a/packages/block-editor/src/store/private-actions.js
+++ b/packages/block-editor/src/store/private-actions.js
@@ -26,6 +26,7 @@ const castArray = ( maybeArray ) =>
const privateSettings = [
'inserterMediaCategories',
'blockInspectorAnimation',
+ 'mediaSideload',
];
/**
diff --git a/packages/editor/README.md b/packages/editor/README.md
index 3211e6664256d..c006ec097982c 100644
--- a/packages/editor/README.md
+++ b/packages/editor/README.md
@@ -499,6 +499,7 @@ _Parameters_
- _$0.maxUploadFileSize_ `?number`: Maximum upload size in bytes allowed for the site.
- _$0.onError_ `Function`: Function called when an error happens.
- _$0.onFileChange_ `Function`: Function called each time a file or a temporary representation of the file is available.
+- _$0.onSuccess_ `Function`: Function called after the final representation of the file is available.
### MediaUploadCheck
diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js
index f5c45f431e2c8..d0c2e36d47443 100644
--- a/packages/editor/src/components/provider/use-block-editor-settings.js
+++ b/packages/editor/src/components/provider/use-block-editor-settings.js
@@ -23,6 +23,7 @@ import {
*/
import inserterMediaCategories from '../media-categories';
import { mediaUpload } from '../../utils';
+import { default as mediaSideload } from '../../utils/media-sideload';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
import { useGlobalStylesContext } from '../global-styles-provider';
@@ -45,6 +46,7 @@ const BLOCK_EDITOR_SETTINGS = [
'__experimentalGlobalStylesBaseStyles',
'alignWide',
'blockInspectorTabs',
+ 'maxUploadFileSize',
'allowedMimeTypes',
'bodyPlaceholder',
'canLockBlocks',
@@ -290,6 +292,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
isDistractionFree,
keepCaretInsideBlock,
mediaUpload: hasUploadPermissions ? mediaUpload : undefined,
+ mediaSideload: hasUploadPermissions ? mediaSideload : undefined,
__experimentalBlockPatterns: blockPatterns,
[ selectBlockPatternsKey ]: ( select ) => {
const { hasFinishedResolution, getBlockPatternsForPostType } =
diff --git a/packages/editor/src/utils/media-sideload/index.js b/packages/editor/src/utils/media-sideload/index.js
new file mode 100644
index 0000000000000..86fcdc688abf8
--- /dev/null
+++ b/packages/editor/src/utils/media-sideload/index.js
@@ -0,0 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { privateApis } from '@wordpress/media-utils';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+
+const { sideloadMedia: mediaSideload } = unlock( privateApis );
+
+export default mediaSideload;
diff --git a/packages/editor/src/utils/media-sideload/index.native.js b/packages/editor/src/utils/media-sideload/index.native.js
new file mode 100644
index 0000000000000..d84a912ec92de
--- /dev/null
+++ b/packages/editor/src/utils/media-sideload/index.native.js
@@ -0,0 +1 @@
+export default function mediaSideload() {}
diff --git a/packages/editor/src/utils/media-upload/index.js b/packages/editor/src/utils/media-upload/index.js
index 6b39db2443cc3..0d970d91ce745 100644
--- a/packages/editor/src/utils/media-upload/index.js
+++ b/packages/editor/src/utils/media-upload/index.js
@@ -27,6 +27,7 @@ const noop = () => {};
* @param {?number} $0.maxUploadFileSize Maximum upload size in bytes allowed for the site.
* @param {Function} $0.onError Function called when an error happens.
* @param {Function} $0.onFileChange Function called each time a file or a temporary representation of the file is available.
+ * @param {Function} $0.onSuccess Function called after the final representation of the file is available.
*/
export default function mediaUpload( {
additionalData = {},
@@ -35,6 +36,7 @@ export default function mediaUpload( {
maxUploadFileSize,
onError = noop,
onFileChange,
+ onSuccess,
} ) {
const { getCurrentPost, getEditorSettings } = select( editorStore );
const {
@@ -77,8 +79,9 @@ export default function mediaUpload( {
} else {
clearSaveLock();
}
- onFileChange( file );
+ onFileChange?.( file );
},
+ onSuccess,
additionalData: {
...postData,
...additionalData,
diff --git a/packages/media-utils/src/utils/types.ts b/packages/media-utils/src/utils/types.ts
index c91d4c67cfc46..c4c6882ea2532 100644
--- a/packages/media-utils/src/utils/types.ts
+++ b/packages/media-utils/src/utils/types.ts
@@ -199,7 +199,6 @@ export type Attachment = BetterOmit<
};
export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void;
-export type OnSuccessHandler = ( attachments: Partial< Attachment >[] ) => void;
export type OnErrorHandler = ( error: Error ) => void;
export type CreateRestAttachment = Partial< RestAttachment >;
diff --git a/packages/media-utils/src/utils/upload-media.ts b/packages/media-utils/src/utils/upload-media.ts
index 1bc861cfb3b60..ff3f718076512 100644
--- a/packages/media-utils/src/utils/upload-media.ts
+++ b/packages/media-utils/src/utils/upload-media.ts
@@ -12,7 +12,6 @@ import type {
Attachment,
OnChangeHandler,
OnErrorHandler,
- OnSuccessHandler,
} from './types';
import { uploadToServer } from './upload-to-server';
import { validateMimeType } from './validate-mime-type';
@@ -20,6 +19,12 @@ import { validateMimeTypeForUser } from './validate-mime-type-for-user';
import { validateFileSize } from './validate-file-size';
import { UploadError } from './upload-error';
+declare global {
+ interface Window {
+ __experimentalMediaProcessing?: boolean;
+ }
+}
+
interface UploadMediaArgs {
// Additional data to include in the request.
additionalData?: AdditionalData;
@@ -33,8 +38,6 @@ interface UploadMediaArgs {
onError?: OnErrorHandler;
// Function called each time a file or a temporary representation of the file is available.
onFileChange?: OnChangeHandler;
- // Function called once a file has completely finished uploading, including thumbnails.
- onSuccess?: OnSuccessHandler;
// List of allowed mime types and file extensions.
wpAllowedMimeTypes?: Record< string, string > | null;
// Abort signal.
@@ -69,8 +72,11 @@ export function uploadMedia( {
const filesSet: Array< Partial< Attachment > | null > = [];
const setAndUpdateFiles = ( index: number, value: Attachment | null ) => {
- if ( filesSet[ index ]?.url ) {
- revokeBlobURL( filesSet[ index ].url );
+ // For client-side media processing, this is handled by the upload-media package.
+ if ( ! window.__experimentalMediaProcessing ) {
+ if ( filesSet[ index ]?.url ) {
+ revokeBlobURL( filesSet[ index ].url );
+ }
}
filesSet[ index ] = value;
onFileChange?.(
@@ -107,10 +113,13 @@ export function uploadMedia( {
validFiles.push( mediaFile );
- // Set temporary URL to create placeholder media file, this is replaced
- // with final file from media gallery when upload is `done` below.
- filesSet.push( { url: createBlobURL( mediaFile ) } );
- onFileChange?.( filesSet as Array< Partial< Attachment > > );
+ // For client-side media processing, this is handled by the upload-media package.
+ if ( ! window.__experimentalMediaProcessing ) {
+ // Set temporary URL to create placeholder media file, this is replaced
+ // with final file from media gallery when upload is `done` below.
+ filesSet.push( { url: createBlobURL( mediaFile ) } );
+ onFileChange?.( filesSet as Array< Partial< Attachment > > );
+ }
}
validFiles.map( async ( file, index ) => {
diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts
index 5a5fb3f39fa18..1ac08a71550ff 100644
--- a/packages/private-apis/src/implementation.ts
+++ b/packages/private-apis/src/implementation.ts
@@ -32,6 +32,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/dataviews',
'@wordpress/fields',
'@wordpress/media-utils',
+ '@wordpress/upload-media',
];
/**
diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md
new file mode 100644
index 0000000000000..e04ce921cdfdc
--- /dev/null
+++ b/packages/upload-media/CHANGELOG.md
@@ -0,0 +1,5 @@
+
+
+## Unreleased
+
+Initial release.
diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md
new file mode 100644
index 0000000000000..982e59148fe87
--- /dev/null
+++ b/packages/upload-media/README.md
@@ -0,0 +1,136 @@
+# (Experimental) Upload Media
+
+This module is a media upload handler with a queue-like system that is implemented using a custom `@wordpress/data` store.
+
+Such a system is useful for additional client-side processing of media files (e.g. image compression) before uploading them to a server.
+
+It is typically used by `@wordpress/block-editor` but can also be leveraged outside of it.
+
+## Installation
+
+Install the module
+
+```bash
+npm install @wordpress/upload-media --save
+```
+
+## Usage
+
+This is a basic example of how one can interact with the upload data store:
+
+```js
+import { store as uploadStore } from '@wordpress/upload-media';
+import { dispatch } from '@wordpress/data';
+
+dispatch( uploadStore ).updateSettings( /* ... */ );
+dispatch( uploadStore ).addItems( [
+ /* ... */
+] );
+```
+
+Refer to the API reference below or the TypeScript types for further help.
+
+## API Reference
+
+### Actions
+
+The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'core/upload-media' )`:
+
+
+
+#### addItems
+
+Adds a new item to the upload queue.
+
+_Parameters_
+
+- _$0_ `AddItemsArgs`:
+- _$0.files_ `AddItemsArgs[ 'files' ]`: Files
+- _$0.onChange_ `[AddItemsArgs[ 'onChange' ]]`: Function called each time a file or a temporary representation of the file is available.
+- _$0.onSuccess_ `[AddItemsArgs[ 'onSuccess' ]]`: Function called after the file is uploaded.
+- _$0.onBatchSuccess_ `[AddItemsArgs[ 'onBatchSuccess' ]]`: Function called after a batch of files is uploaded.
+- _$0.onError_ `[AddItemsArgs[ 'onError' ]]`: Function called when an error happens.
+- _$0.additionalData_ `[AddItemsArgs[ 'additionalData' ]]`: Additional data to include in the request.
+- _$0.allowedTypes_ `[AddItemsArgs[ 'allowedTypes' ]]`: Array with the types of media that can be uploaded, if unset all types are allowed.
+
+#### cancelItem
+
+Cancels an item in the queue based on an error.
+
+_Parameters_
+
+- _id_ `QueueItemId`: Item ID.
+- _error_ `Error`: Error instance.
+- _silent_ Whether to cancel the item silently, without invoking its `onError` callback.
+
+
+
+### Selectors
+
+The following selectors are available on the object returned by `wp.data.select( 'core/upload-media' )`:
+
+
+
+#### getItems
+
+Returns all items currently being uploaded.
+
+_Parameters_
+
+- _state_ `State`: Upload state.
+
+_Returns_
+
+- `QueueItem[]`: Queue items.
+
+#### getSettings
+
+Returns the media upload settings.
+
+_Parameters_
+
+- _state_ `State`: Upload state.
+
+_Returns_
+
+- `Settings`: Settings
+
+#### isUploading
+
+Determines whether any upload is currently in progress.
+
+_Parameters_
+
+- _state_ `State`: Upload state.
+
+_Returns_
+
+- `boolean`: Whether any upload is currently in progress.
+
+#### isUploadingById
+
+Determines whether an upload is currently in progress given an attachment ID.
+
+_Parameters_
+
+- _state_ `State`: Upload state.
+- _attachmentId_ `number`: Attachment ID.
+
+_Returns_
+
+- `boolean`: Whether upload is currently in progress for the given attachment.
+
+#### isUploadingByUrl
+
+Determines whether an upload is currently in progress given an attachment URL.
+
+_Parameters_
+
+- _state_ `State`: Upload state.
+- _url_ `string`: Attachment URL.
+
+_Returns_
+
+- `boolean`: Whether upload is currently in progress for the given attachment.
+
+
diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json
new file mode 100644
index 0000000000000..ec7eaabbb3940
--- /dev/null
+++ b/packages/upload-media/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@wordpress/upload-media",
+ "version": "1.0.0-prerelease",
+ "private": true,
+ "description": "Core media upload logic.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "media"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/upload-media/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/upload-media"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "wpScript": true,
+ "types": "build-types",
+ "dependencies": {
+ "@shopify/web-worker": "^6.4.0",
+ "@wordpress/api-fetch": "file:../api-fetch",
+ "@wordpress/blob": "file:../blob",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/preferences": "file:../preferences",
+ "@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/url": "file:../url",
+ "uuid": "^9.0.1"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/upload-media/src/components/provider/index.tsx b/packages/upload-media/src/components/provider/index.tsx
new file mode 100644
index 0000000000000..0bc187e6a1d86
--- /dev/null
+++ b/packages/upload-media/src/components/provider/index.tsx
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { useDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import withRegistryProvider from './with-registry-provider';
+import { unlock } from '../../lock-unlock';
+import { store as uploadStore } from '../../store';
+
+const MediaUploadProvider = withRegistryProvider( ( props: any ) => {
+ const { children, settings } = props;
+ const { updateSettings } = unlock( useDispatch( uploadStore ) );
+
+ useEffect( () => {
+ updateSettings( settings );
+ }, [ settings, updateSettings ] );
+
+ return <>{ children }>;
+} );
+
+export default MediaUploadProvider;
diff --git a/packages/upload-media/src/components/provider/with-registry-provider.tsx b/packages/upload-media/src/components/provider/with-registry-provider.tsx
new file mode 100644
index 0000000000000..9a47a5601d33e
--- /dev/null
+++ b/packages/upload-media/src/components/provider/with-registry-provider.tsx
@@ -0,0 +1,59 @@
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+import { useRegistry, createRegistry, RegistryProvider } from '@wordpress/data';
+import { createHigherOrderComponent } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import { storeConfig } from '../../store';
+import { STORE_NAME as mediaUploadStoreName } from '../../store/constants';
+
+type WPDataRegistry = ReturnType< typeof createRegistry >;
+
+function getSubRegistry(
+ subRegistries: WeakMap< WPDataRegistry, WPDataRegistry >,
+ registry: WPDataRegistry,
+ useSubRegistry: boolean
+) {
+ if ( ! useSubRegistry ) {
+ return registry;
+ }
+ let subRegistry = subRegistries.get( registry );
+ if ( ! subRegistry ) {
+ subRegistry = createRegistry( {}, registry );
+ subRegistry.registerStore( mediaUploadStoreName, storeConfig );
+ subRegistries.set( registry, subRegistry );
+ }
+ return subRegistry;
+}
+
+const withRegistryProvider = createHigherOrderComponent(
+ ( WrappedComponent ) =>
+ ( { useSubRegistry = true, ...props } ) => {
+ const registry = useRegistry() as unknown as WPDataRegistry;
+ const [ subRegistries ] = useState<
+ WeakMap< WPDataRegistry, WPDataRegistry >
+ >( () => new WeakMap() );
+ const subRegistry = getSubRegistry(
+ subRegistries,
+ registry,
+ useSubRegistry
+ );
+
+ if ( subRegistry === registry ) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+ },
+ 'withRegistryProvider'
+);
+
+export default withRegistryProvider;
diff --git a/packages/upload-media/src/get-mime-types-array.ts b/packages/upload-media/src/get-mime-types-array.ts
new file mode 100644
index 0000000000000..d4940d36cd6ae
--- /dev/null
+++ b/packages/upload-media/src/get-mime-types-array.ts
@@ -0,0 +1,29 @@
+/**
+ * Browsers may use unexpected mime types, and they differ from browser to browser.
+ * This function computes a flexible array of mime types from the mime type structured provided by the server.
+ * Converts { jpg|jpeg|jpe: "image/jpeg" } into [ "image/jpeg", "image/jpg", "image/jpeg", "image/jpe" ]
+ *
+ * @param {?Object} wpMimeTypesObject Mime type object received from the server.
+ * Extensions are keys separated by '|' and values are mime types associated with an extension.
+ *
+ * @return An array of mime types or null
+ */
+export function getMimeTypesArray(
+ wpMimeTypesObject?: Record< string, string > | null
+) {
+ if ( ! wpMimeTypesObject ) {
+ return null;
+ }
+ return Object.entries( wpMimeTypesObject ).flatMap(
+ ( [ extensionsString, mime ] ) => {
+ const [ type ] = mime.split( '/' );
+ const extensions = extensionsString.split( '|' );
+ return [
+ mime,
+ ...extensions.map(
+ ( extension ) => `${ type }/${ extension }`
+ ),
+ ];
+ }
+ );
+}
diff --git a/packages/upload-media/src/image-file.ts b/packages/upload-media/src/image-file.ts
new file mode 100644
index 0000000000000..2c1a43ee1ab67
--- /dev/null
+++ b/packages/upload-media/src/image-file.ts
@@ -0,0 +1,38 @@
+/**
+ * ImageFile class.
+ *
+ * Small wrapper around the `File` class to hold
+ * information about current dimensions and original
+ * dimensions, in case the image was resized.
+ */
+export class ImageFile extends File {
+ width = 0;
+ height = 0;
+ originalWidth? = 0;
+ originalHeight? = 0;
+
+ get wasResized() {
+ return (
+ ( this.originalWidth || 0 ) > this.width ||
+ ( this.originalHeight || 0 ) > this.height
+ );
+ }
+
+ constructor(
+ file: File,
+ width: number,
+ height: number,
+ originalWidth?: number,
+ originalHeight?: number
+ ) {
+ super( [ file ], file.name, {
+ type: file.type,
+ lastModified: file.lastModified,
+ } );
+
+ this.width = width;
+ this.height = height;
+ this.originalWidth = originalWidth;
+ this.originalHeight = originalHeight;
+ }
+}
diff --git a/packages/upload-media/src/index.ts b/packages/upload-media/src/index.ts
new file mode 100644
index 0000000000000..d105c2dba9039
--- /dev/null
+++ b/packages/upload-media/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+import { store as uploadStore } from './store';
+
+export { uploadStore as store };
+
+export { default as MediaUploadProvider } from './components/provider';
+export { UploadError } from './upload-error';
+
+export type { ImageFormat } from './store/types';
diff --git a/packages/upload-media/src/lock-unlock.ts b/packages/upload-media/src/lock-unlock.ts
new file mode 100644
index 0000000000000..5089cb80e4bb4
--- /dev/null
+++ b/packages/upload-media/src/lock-unlock.ts
@@ -0,0 +1,10 @@
+/**
+ * WordPress dependencies
+ */
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+
+export const { lock, unlock } =
+ __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
+ '@wordpress/upload-media'
+ );
diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts
new file mode 100644
index 0000000000000..4cc3c3e31ae0e
--- /dev/null
+++ b/packages/upload-media/src/store/actions.ts
@@ -0,0 +1,183 @@
+/**
+ * External dependencies
+ */
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import type { createRegistry } from '@wordpress/data';
+
+type WPDataRegistry = ReturnType< typeof createRegistry >;
+
+/**
+ * Internal dependencies
+ */
+import type {
+ AdditionalData,
+ CancelAction,
+ OnBatchSuccessHandler,
+ OnChangeHandler,
+ OnErrorHandler,
+ OnSuccessHandler,
+ QueueItemId,
+ State,
+} from './types';
+import { Type } from './types';
+import type {
+ addItem,
+ processItem,
+ removeItem,
+ revokeBlobUrls,
+} from './private-actions';
+import { validateMimeType } from '../validate-mime-type';
+import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
+import { validateFileSize } from '../validate-file-size';
+
+type ActionCreators = {
+ addItem: typeof addItem;
+ addItems: typeof addItems;
+ removeItem: typeof removeItem;
+ processItem: typeof processItem;
+ cancelItem: typeof cancelItem;
+ revokeBlobUrls: typeof revokeBlobUrls;
+ < T = Record< string, unknown > >( args: T ): void;
+};
+
+type AllSelectors = typeof import('./selectors') &
+ typeof import('./private-selectors');
+type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R
+ ? ( ...args: P ) => R
+ : F;
+type Selectors = {
+ [ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >;
+};
+
+type ThunkArgs = {
+ select: Selectors;
+ dispatch: ActionCreators;
+ registry: WPDataRegistry;
+};
+
+interface AddItemsArgs {
+ files: File[];
+ onChange?: OnChangeHandler;
+ onSuccess?: OnSuccessHandler;
+ onBatchSuccess?: OnBatchSuccessHandler;
+ onError?: OnErrorHandler;
+ additionalData?: AdditionalData;
+ allowedTypes?: string[];
+}
+
+/**
+ * Adds a new item to the upload queue.
+ *
+ * @param $0
+ * @param $0.files Files
+ * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available.
+ * @param [$0.onSuccess] Function called after the file is uploaded.
+ * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded.
+ * @param [$0.onError] Function called when an error happens.
+ * @param [$0.additionalData] Additional data to include in the request.
+ * @param [$0.allowedTypes] Array with the types of media that can be uploaded, if unset all types are allowed.
+ */
+export function addItems( {
+ files,
+ onChange,
+ onSuccess,
+ onError,
+ onBatchSuccess,
+ additionalData,
+ allowedTypes,
+}: AddItemsArgs ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const batchId = uuidv4();
+ for ( const file of files ) {
+ /*
+ Check if the caller (e.g. a block) supports this mime type.
+ Special case for file types such as HEIC which will be converted before upload anyway.
+ Another check will be done before upload.
+ */
+ try {
+ validateMimeType( file, allowedTypes );
+ validateMimeTypeForUser(
+ file,
+ select.getSettings().allowedMimeTypes
+ );
+ } catch ( error: unknown ) {
+ onError?.( error as Error );
+ continue;
+ }
+
+ try {
+ validateFileSize(
+ file,
+ select.getSettings().maxUploadFileSize
+ );
+ } catch ( error: unknown ) {
+ onError?.( error as Error );
+ continue;
+ }
+
+ dispatch.addItem( {
+ file,
+ batchId,
+ onChange,
+ onSuccess,
+ onBatchSuccess,
+ onError,
+ additionalData,
+ } );
+ }
+ };
+}
+
+/**
+ * Cancels an item in the queue based on an error.
+ *
+ * @param id Item ID.
+ * @param error Error instance.
+ * @param silent Whether to cancel the item silently,
+ * without invoking its `onError` callback.
+ */
+export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+
+ if ( ! item ) {
+ /*
+ * Do nothing if item has already been removed.
+ * This can happen if an upload is cancelled manually
+ * while transcoding with vips is still in progress.
+ * Then, cancelItem() is once invoked manually and once
+ * by the error handler in optimizeImageItem().
+ */
+ return;
+ }
+
+ item.abortController?.abort();
+
+ if ( ! silent ) {
+ const { onError } = item;
+ onError?.( error ?? new Error( 'Upload cancelled' ) );
+ if ( ! onError && error ) {
+ // TODO: Find better way to surface errors with sideloads etc.
+ // eslint-disable-next-line no-console -- Deliberately log errors here.
+ console.error( 'Upload cancelled', error );
+ }
+ }
+
+ dispatch< CancelAction >( {
+ type: Type.Cancel,
+ id,
+ error,
+ } );
+ dispatch.removeItem( id );
+ dispatch.revokeBlobUrls( id );
+
+ // All items of this batch were cancelled or finished.
+ if ( item.batchId && select.isBatchUploaded( item.batchId ) ) {
+ item.onBatchSuccess?.();
+ }
+ };
+}
diff --git a/packages/upload-media/src/store/constants.ts b/packages/upload-media/src/store/constants.ts
new file mode 100644
index 0000000000000..ad0960cf62f46
--- /dev/null
+++ b/packages/upload-media/src/store/constants.ts
@@ -0,0 +1 @@
+export const STORE_NAME = 'core/upload-media';
diff --git a/packages/upload-media/src/store/index.ts b/packages/upload-media/src/store/index.ts
new file mode 100644
index 0000000000000..c74f59ea7a7cf
--- /dev/null
+++ b/packages/upload-media/src/store/index.ts
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { createReduxStore, register } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import reducer from './reducer';
+import * as selectors from './selectors';
+import * as privateSelectors from './private-selectors';
+import * as actions from './actions';
+import * as privateActions from './private-actions';
+import { unlock } from '../lock-unlock';
+import { STORE_NAME } from './constants';
+
+/**
+ * Media upload data store configuration.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#registerStore
+ */
+export const storeConfig = {
+ reducer,
+ selectors,
+ actions,
+};
+
+/**
+ * Store definition for the media upload namespace.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore
+ */
+export const store = createReduxStore( STORE_NAME, {
+ reducer,
+ selectors,
+ actions,
+} );
+
+register( store );
+// @ts-ignore
+unlock( store ).registerPrivateActions( privateActions );
+// @ts-ignore
+unlock( store ).registerPrivateSelectors( privateSelectors );
diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts
new file mode 100644
index 0000000000000..a4d4ee7b99c78
--- /dev/null
+++ b/packages/upload-media/src/store/private-actions.ts
@@ -0,0 +1,407 @@
+/**
+ * External dependencies
+ */
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
+import type { createRegistry } from '@wordpress/data';
+
+type WPDataRegistry = ReturnType< typeof createRegistry >;
+
+/**
+ * Internal dependencies
+ */
+import { cloneFile, convertBlobToFile } from '../utils';
+import { StubFile } from '../stub-file';
+import type {
+ AddAction,
+ AdditionalData,
+ AddOperationsAction,
+ BatchId,
+ CacheBlobUrlAction,
+ OnBatchSuccessHandler,
+ OnChangeHandler,
+ OnErrorHandler,
+ OnSuccessHandler,
+ Operation,
+ OperationFinishAction,
+ OperationStartAction,
+ PauseQueueAction,
+ QueueItem,
+ QueueItemId,
+ ResumeQueueAction,
+ RevokeBlobUrlsAction,
+ Settings,
+ State,
+ UpdateSettingsAction,
+} from './types';
+import { ItemStatus, OperationType, Type } from './types';
+import type { cancelItem } from './actions';
+
+type ActionCreators = {
+ cancelItem: typeof cancelItem;
+ addItem: typeof addItem;
+ removeItem: typeof removeItem;
+ prepareItem: typeof prepareItem;
+ processItem: typeof processItem;
+ finishOperation: typeof finishOperation;
+ uploadItem: typeof uploadItem;
+ revokeBlobUrls: typeof revokeBlobUrls;
+ < T = Record< string, unknown > >( args: T ): void;
+};
+
+type AllSelectors = typeof import('./selectors') &
+ typeof import('./private-selectors');
+type CurriedState< F > = F extends ( state: State, ...args: infer P ) => infer R
+ ? ( ...args: P ) => R
+ : F;
+type Selectors = {
+ [ key in keyof AllSelectors ]: CurriedState< AllSelectors[ key ] >;
+};
+
+type ThunkArgs = {
+ select: Selectors;
+ dispatch: ActionCreators;
+ registry: WPDataRegistry;
+};
+
+interface AddItemArgs {
+ // It should always be a File, but some consumers might still pass Blobs only.
+ file: File | Blob;
+ batchId?: BatchId;
+ onChange?: OnChangeHandler;
+ onSuccess?: OnSuccessHandler;
+ onError?: OnErrorHandler;
+ onBatchSuccess?: OnBatchSuccessHandler;
+ additionalData?: AdditionalData;
+ sourceUrl?: string;
+ sourceAttachmentId?: number;
+ abortController?: AbortController;
+ operations?: Operation[];
+}
+
+/**
+ * Adds a new item to the upload queue.
+ *
+ * @param $0
+ * @param $0.file File
+ * @param [$0.batchId] Batch ID.
+ * @param [$0.onChange] Function called each time a file or a temporary representation of the file is available.
+ * @param [$0.onSuccess] Function called after the file is uploaded.
+ * @param [$0.onBatchSuccess] Function called after a batch of files is uploaded.
+ * @param [$0.onError] Function called when an error happens.
+ * @param [$0.additionalData] Additional data to include in the request.
+ * @param [$0.sourceUrl] Source URL. Used when importing a file from a URL or optimizing an existing file.
+ * @param [$0.sourceAttachmentId] Source attachment ID. Used when optimizing an existing file for example.
+ * @param [$0.abortController] Abort controller for upload cancellation.
+ * @param [$0.operations] List of operations to perform. Defaults to automatically determined list, based on the file.
+ */
+export function addItem( {
+ file: fileOrBlob,
+ batchId,
+ onChange,
+ onSuccess,
+ onBatchSuccess,
+ onError,
+ additionalData = {} as AdditionalData,
+ sourceUrl,
+ sourceAttachmentId,
+ abortController,
+ operations,
+}: AddItemArgs ) {
+ return async ( { dispatch }: ThunkArgs ) => {
+ const itemId = uuidv4();
+
+ // Hardening in case a Blob is passed instead of a File.
+ // See https://github.com/WordPress/gutenberg/pull/65693 for an example.
+ const file = convertBlobToFile( fileOrBlob );
+
+ let blobUrl;
+
+ // StubFile could be coming from addItemFromUrl().
+ if ( ! ( file instanceof StubFile ) ) {
+ blobUrl = createBlobURL( file );
+ dispatch< CacheBlobUrlAction >( {
+ type: Type.CacheBlobUrl,
+ id: itemId,
+ blobUrl,
+ } );
+ }
+
+ dispatch< AddAction >( {
+ type: Type.Add,
+ item: {
+ id: itemId,
+ batchId,
+ status: ItemStatus.Processing,
+ sourceFile: cloneFile( file ),
+ file,
+ attachment: {
+ url: blobUrl,
+ },
+ additionalData: {
+ convert_format: false,
+ ...additionalData,
+ },
+ onChange,
+ onSuccess,
+ onBatchSuccess,
+ onError,
+ sourceUrl,
+ sourceAttachmentId,
+ abortController: abortController || new AbortController(),
+ operations: Array.isArray( operations )
+ ? operations
+ : [ OperationType.Prepare ],
+ },
+ } );
+
+ dispatch.processItem( itemId );
+ };
+}
+
+/**
+ * Processes a single item in the queue.
+ *
+ * Runs the next operation in line and invokes any callbacks.
+ *
+ * @param id Item ID.
+ */
+export function processItem( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ if ( select.isPaused() ) {
+ return;
+ }
+
+ const item = select.getItem( id ) as QueueItem;
+
+ const { attachment, onChange, onSuccess, onBatchSuccess, batchId } =
+ item;
+
+ const operation = Array.isArray( item.operations?.[ 0 ] )
+ ? item.operations[ 0 ][ 0 ]
+ : item.operations?.[ 0 ];
+
+ if ( attachment ) {
+ onChange?.( [ attachment ] );
+ }
+
+ /*
+ If there are no more operations, the item can be removed from the queue,
+ but only if there are no thumbnails still being side-loaded,
+ or if itself is a side-loaded item.
+ */
+
+ if ( ! operation ) {
+ if ( attachment ) {
+ onSuccess?.( [ attachment ] );
+ }
+
+ // dispatch.removeItem( id );
+ dispatch.revokeBlobUrls( id );
+
+ if ( batchId && select.isBatchUploaded( batchId ) ) {
+ onBatchSuccess?.();
+ }
+
+ /*
+ At this point we are dealing with a parent whose children haven't fully uploaded yet.
+ Do nothing and let the removal happen once the last side-loaded item finishes.
+ */
+
+ return;
+ }
+
+ if ( ! operation ) {
+ // This shouldn't really happen.
+ return;
+ }
+
+ dispatch< OperationStartAction >( {
+ type: Type.OperationStart,
+ id,
+ operation,
+ } );
+
+ switch ( operation ) {
+ case OperationType.Prepare:
+ dispatch.prepareItem( item.id );
+ break;
+
+ case OperationType.Upload:
+ dispatch.uploadItem( id );
+ break;
+ }
+ };
+}
+
+/**
+ * Returns an action object that pauses all processing in the queue.
+ *
+ * Useful for testing purposes.
+ *
+ * @return Action object.
+ */
+export function pauseQueue(): PauseQueueAction {
+ return {
+ type: Type.PauseQueue,
+ };
+}
+
+/**
+ * Resumes all processing in the queue.
+ *
+ * Dispatches an action object for resuming the queue itself,
+ * and triggers processing for each remaining item in the queue individually.
+ */
+export function resumeQueue() {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ dispatch< ResumeQueueAction >( {
+ type: Type.ResumeQueue,
+ } );
+
+ for ( const item of select.getAllItems() ) {
+ dispatch.processItem( item.id );
+ }
+ };
+}
+
+/**
+ * Removes a specific item from the queue.
+ *
+ * @param id Item ID.
+ */
+export function removeItem( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id );
+ if ( ! item ) {
+ return;
+ }
+
+ dispatch( {
+ type: Type.Remove,
+ id,
+ } );
+ };
+}
+
+/**
+ * Finishes an operation for a given item ID and immediately triggers processing the next one.
+ *
+ * @param id Item ID.
+ * @param updates Updated item data.
+ */
+export function finishOperation(
+ id: QueueItemId,
+ updates: Partial< QueueItem >
+) {
+ return async ( { dispatch }: ThunkArgs ) => {
+ dispatch< OperationFinishAction >( {
+ type: Type.OperationFinish,
+ id,
+ item: updates,
+ } );
+
+ dispatch.processItem( id );
+ };
+}
+
+/**
+ * Prepares an item for initial processing.
+ *
+ * Determines the list of operations to perform for a given image,
+ * depending on its media type.
+ *
+ * For example, HEIF images first need to be converted, resized,
+ * compressed, and then uploaded.
+ *
+ * Or videos need to be compressed, and then need poster generation
+ * before upload.
+ *
+ * @param id Item ID.
+ */
+export function prepareItem( id: QueueItemId ) {
+ return async ( { dispatch }: ThunkArgs ) => {
+ const operations: Operation[] = [ OperationType.Upload ];
+
+ dispatch< AddOperationsAction >( {
+ type: Type.AddOperations,
+ id,
+ operations,
+ } );
+
+ dispatch.finishOperation( id, {} );
+ };
+}
+
+/**
+ * Uploads an item to the server.
+ *
+ * @param id Item ID.
+ */
+export function uploadItem( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const item = select.getItem( id ) as QueueItem;
+
+ select.getSettings().mediaUpload( {
+ filesList: [ item.file ],
+ additionalData: item.additionalData,
+ signal: item.abortController?.signal,
+ onFileChange: ( [ attachment ] ) => {
+ if ( ! isBlobURL( attachment.url ) ) {
+ dispatch.finishOperation( id, {
+ attachment,
+ } );
+ }
+ },
+ onSuccess: ( [ attachment ] ) => {
+ dispatch.finishOperation( id, {
+ attachment,
+ } );
+ },
+ onError: ( error ) => {
+ dispatch.cancelItem( id, error );
+ },
+ } );
+ };
+}
+
+/**
+ * Revokes all blob URLs for a given item, freeing up memory.
+ *
+ * @param id Item ID.
+ */
+export function revokeBlobUrls( id: QueueItemId ) {
+ return async ( { select, dispatch }: ThunkArgs ) => {
+ const blobUrls = select.getBlobUrls( id );
+
+ for ( const blobUrl of blobUrls ) {
+ revokeBlobURL( blobUrl );
+ }
+
+ dispatch< RevokeBlobUrlsAction >( {
+ type: Type.RevokeBlobUrls,
+ id,
+ } );
+ };
+}
+
+/**
+ * Returns an action object that pauses all processing in the queue.
+ *
+ * Useful for testing purposes.
+ *
+ * @param settings
+ * @return Action object.
+ */
+export function updateSettings(
+ settings: Partial< Settings >
+): UpdateSettingsAction {
+ return {
+ type: Type.UpdateSettings,
+ settings,
+ };
+}
diff --git a/packages/upload-media/src/store/private-selectors.ts b/packages/upload-media/src/store/private-selectors.ts
new file mode 100644
index 0000000000000..f2cfdbef76df8
--- /dev/null
+++ b/packages/upload-media/src/store/private-selectors.ts
@@ -0,0 +1,113 @@
+/**
+ * Internal dependencies
+ */
+import {
+ type BatchId,
+ ItemStatus,
+ OperationType,
+ type QueueItem,
+ type QueueItemId,
+ type State,
+} from './types';
+
+/**
+ * Returns all items currently being uploaded.
+ *
+ * @param state Upload state.
+ *
+ * @return Queue items.
+ */
+export function getAllItems( state: State ): QueueItem[] {
+ return state.queue;
+}
+
+/**
+ * Returns a specific item given its unique ID.
+ *
+ * @param state Upload state.
+ * @param id Item ID.
+ *
+ * @return Queue item.
+ */
+export function getItem(
+ state: State,
+ id: QueueItemId
+): QueueItem | undefined {
+ return state.queue.find( ( item ) => item.id === id );
+}
+
+/**
+ * Determines whether a batch has been successfully uploaded, given its unique ID.
+ *
+ * @param state Upload state.
+ * @param batchId Batch ID.
+ *
+ * @return Whether a batch has been uploaded.
+ */
+export function isBatchUploaded( state: State, batchId: BatchId ): boolean {
+ const batchItems = state.queue.filter(
+ ( item ) => batchId === item.batchId
+ );
+ return batchItems.length === 0;
+}
+
+/**
+ * Determines whether an upload is currently in progress given a post or attachment ID.
+ *
+ * @param state Upload state.
+ * @param postOrAttachmentId Post ID or attachment ID.
+ *
+ * @return Whether upload is currently in progress for the given post or attachment.
+ */
+export function isUploadingToPost(
+ state: State,
+ postOrAttachmentId: number
+): boolean {
+ return state.queue.some(
+ ( item ) =>
+ item.currentOperation === OperationType.Upload &&
+ item.additionalData.post === postOrAttachmentId
+ );
+}
+
+/**
+ * Returns the next paused upload for a given post or attachment ID.
+ *
+ * @param state Upload state.
+ * @param postOrAttachmentId Post ID or attachment ID.
+ *
+ * @return Paused item.
+ */
+export function getPausedUploadForPost(
+ state: State,
+ postOrAttachmentId: number
+): QueueItem | undefined {
+ return state.queue.find(
+ ( item ) =>
+ item.status === ItemStatus.Paused &&
+ item.additionalData.post === postOrAttachmentId
+ );
+}
+
+/**
+ * Determines whether uploading is currently paused.
+ *
+ * @param state Upload state.
+ *
+ * @return Whether uploading is currently paused.
+ */
+export function isPaused( state: State ): boolean {
+ return state.queueStatus === 'paused';
+}
+
+/**
+ * Returns all cached blob URLs for a given item ID.
+ *
+ * @param state Upload state.
+ * @param id Item ID
+ *
+ * @return List of blob URLs.
+ */
+export function getBlobUrls( state: State, id: QueueItemId ): string[] {
+ return state.blobUrls[ id ] || [];
+}
diff --git a/packages/upload-media/src/store/reducer.ts b/packages/upload-media/src/store/reducer.ts
new file mode 100644
index 0000000000000..290a319fcbc1d
--- /dev/null
+++ b/packages/upload-media/src/store/reducer.ts
@@ -0,0 +1,195 @@
+/**
+ * Internal dependencies
+ */
+import {
+ type AddAction,
+ type AddOperationsAction,
+ type CacheBlobUrlAction,
+ type CancelAction,
+ type OperationFinishAction,
+ type OperationStartAction,
+ type PauseQueueAction,
+ type QueueItem,
+ type RemoveAction,
+ type ResumeQueueAction,
+ type RevokeBlobUrlsAction,
+ type State,
+ Type,
+ type UnknownAction,
+ type UpdateSettingsAction,
+} from './types';
+
+const noop = () => {};
+
+const DEFAULT_STATE: State = {
+ queue: [],
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: noop,
+ },
+};
+
+type Action =
+ | AddAction
+ | RemoveAction
+ | CancelAction
+ | PauseQueueAction
+ | ResumeQueueAction
+ | AddOperationsAction
+ | OperationFinishAction
+ | OperationStartAction
+ | CacheBlobUrlAction
+ | RevokeBlobUrlsAction
+ | UpdateSettingsAction
+ | UnknownAction;
+
+function reducer(
+ state = DEFAULT_STATE,
+ action: Action = { type: Type.Unknown }
+) {
+ switch ( action.type ) {
+ case Type.PauseQueue: {
+ return {
+ ...state,
+ queueStatus: 'paused',
+ };
+ }
+
+ case Type.ResumeQueue: {
+ return {
+ ...state,
+ queueStatus: 'active',
+ };
+ }
+
+ case Type.Add:
+ return {
+ ...state,
+ queue: [ ...state.queue, action.item ],
+ };
+
+ case Type.Cancel:
+ return {
+ ...state,
+ queue: state.queue.map(
+ ( item ): QueueItem =>
+ item.id === action.id
+ ? {
+ ...item,
+ error: action.error,
+ }
+ : item
+ ),
+ };
+
+ case Type.Remove:
+ return {
+ ...state,
+ queue: state.queue.filter( ( item ) => item.id !== action.id ),
+ };
+
+ case Type.OperationStart: {
+ return {
+ ...state,
+ queue: state.queue.map(
+ ( item ): QueueItem =>
+ item.id === action.id
+ ? {
+ ...item,
+ currentOperation: action.operation,
+ }
+ : item
+ ),
+ };
+ }
+
+ case Type.AddOperations:
+ return {
+ ...state,
+ queue: state.queue.map( ( item ): QueueItem => {
+ if ( item.id !== action.id ) {
+ return item;
+ }
+
+ return {
+ ...item,
+ operations: [
+ ...( item.operations || [] ),
+ ...action.operations,
+ ],
+ };
+ } ),
+ };
+
+ case Type.OperationFinish:
+ return {
+ ...state,
+ queue: state.queue.map( ( item ): QueueItem => {
+ if ( item.id !== action.id ) {
+ return item;
+ }
+
+ const operations = item.operations
+ ? item.operations.slice( 1 )
+ : [];
+
+ // Prevent an empty object if there's no attachment data.
+ const attachment =
+ item.attachment || action.item.attachment
+ ? {
+ ...item.attachment,
+ ...action.item.attachment,
+ }
+ : undefined;
+
+ return {
+ ...item,
+ currentOperation: undefined,
+ operations,
+ ...action.item,
+ attachment,
+ additionalData: {
+ ...item.additionalData,
+ ...action.item.additionalData,
+ },
+ };
+ } ),
+ };
+
+ case Type.CacheBlobUrl: {
+ const blobUrls = state.blobUrls[ action.id ] || [];
+ return {
+ ...state,
+ blobUrls: {
+ ...state.blobUrls,
+ [ action.id ]: [ ...blobUrls, action.blobUrl ],
+ },
+ };
+ }
+
+ case Type.RevokeBlobUrls: {
+ const newBlobUrls = { ...state.blobUrls };
+ delete newBlobUrls[ action.id ];
+
+ return {
+ ...state,
+ blobUrls: newBlobUrls,
+ };
+ }
+
+ case Type.UpdateSettings: {
+ return {
+ ...state,
+ settings: {
+ ...state.settings,
+ ...action.settings,
+ },
+ };
+ }
+ }
+
+ return state;
+}
+
+export default reducer;
diff --git a/packages/upload-media/src/store/selectors.ts b/packages/upload-media/src/store/selectors.ts
new file mode 100644
index 0000000000000..8bcb8c5d63b6a
--- /dev/null
+++ b/packages/upload-media/src/store/selectors.ts
@@ -0,0 +1,67 @@
+/**
+ * Internal dependencies
+ */
+import type { QueueItem, Settings, State } from './types';
+
+/**
+ * Returns all items currently being uploaded.
+ *
+ * @param state Upload state.
+ *
+ * @return Queue items.
+ */
+export function getItems( state: State ): QueueItem[] {
+ return state.queue;
+}
+
+/**
+ * Determines whether any upload is currently in progress.
+ *
+ * @param state Upload state.
+ *
+ * @return Whether any upload is currently in progress.
+ */
+export function isUploading( state: State ): boolean {
+ return state.queue.length >= 1;
+}
+
+/**
+ * Determines whether an upload is currently in progress given an attachment URL.
+ *
+ * @param state Upload state.
+ * @param url Attachment URL.
+ *
+ * @return Whether upload is currently in progress for the given attachment.
+ */
+export function isUploadingByUrl( state: State, url: string ): boolean {
+ return state.queue.some(
+ ( item ) => item.attachment?.url === url || item.sourceUrl === url
+ );
+}
+
+/**
+ * Determines whether an upload is currently in progress given an attachment ID.
+ *
+ * @param state Upload state.
+ * @param attachmentId Attachment ID.
+ *
+ * @return Whether upload is currently in progress for the given attachment.
+ */
+export function isUploadingById( state: State, attachmentId: number ): boolean {
+ return state.queue.some(
+ ( item ) =>
+ item.attachment?.id === attachmentId ||
+ item.sourceAttachmentId === attachmentId
+ );
+}
+
+/**
+ * Returns the media upload settings.
+ *
+ * @param state Upload state.
+ *
+ * @return Settings
+ */
+export function getSettings( state: State ): Settings {
+ return state.settings;
+}
diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts
new file mode 100644
index 0000000000000..adb38ab27128e
--- /dev/null
+++ b/packages/upload-media/src/store/test/actions.ts
@@ -0,0 +1,112 @@
+/**
+ * WordPress dependencies
+ */
+import { createRegistry } from '@wordpress/data';
+
+type WPDataRegistry = ReturnType< typeof createRegistry >;
+
+/**
+ * Internal dependencies
+ */
+import { store as uploadStore } from '..';
+import { ItemStatus } from '../types';
+import { unlock } from '../../lock-unlock';
+
+jest.mock( '@wordpress/blob', () => ( {
+ __esModule: true,
+ createBlobURL: jest.fn( () => 'blob:foo' ),
+ isBlobURL: jest.fn( ( str: string ) => str.startsWith( 'blob:' ) ),
+ revokeBlobURL: jest.fn(),
+} ) );
+
+function createRegistryWithStores() {
+ // Create a registry and register used stores.
+ const registry = createRegistry();
+ // @ts-ignore
+ [ uploadStore ].forEach( registry.register );
+ return registry;
+}
+
+const jpegFile = new File( [ 'foo' ], 'example.jpg', {
+ lastModified: 1234567891,
+ type: 'image/jpeg',
+} );
+
+const mp4File = new File( [ 'foo' ], 'amazing-video.mp4', {
+ lastModified: 1234567891,
+ type: 'video/mp4',
+} );
+
+describe( 'actions', () => {
+ let registry: WPDataRegistry;
+ beforeEach( () => {
+ registry = createRegistryWithStores();
+ unlock( registry.dispatch( uploadStore ) ).pauseQueue();
+ } );
+
+ describe( 'addItem', () => {
+ it( 'adds an item to the queue', () => {
+ unlock( registry.dispatch( uploadStore ) ).addItem( {
+ file: jpegFile,
+ } );
+
+ expect( registry.select( uploadStore ).getItems() ).toHaveLength(
+ 1
+ );
+ expect(
+ registry.select( uploadStore ).getItems()[ 0 ]
+ ).toStrictEqual(
+ expect.objectContaining( {
+ id: expect.any( String ),
+ file: jpegFile,
+ sourceFile: jpegFile,
+ status: ItemStatus.Processing,
+ attachment: {
+ url: expect.stringMatching( /^blob:/ ),
+ },
+ } )
+ );
+ } );
+ } );
+
+ describe( 'addItems', () => {
+ it( 'adds multiple items to the queue', () => {
+ const onError = jest.fn();
+ registry.dispatch( uploadStore ).addItems( {
+ files: [ jpegFile, mp4File ],
+ onError,
+ } );
+
+ expect( onError ).not.toHaveBeenCalled();
+ expect( registry.select( uploadStore ).getItems() ).toHaveLength(
+ 2
+ );
+ expect(
+ registry.select( uploadStore ).getItems()[ 0 ]
+ ).toStrictEqual(
+ expect.objectContaining( {
+ id: expect.any( String ),
+ file: jpegFile,
+ sourceFile: jpegFile,
+ status: ItemStatus.Processing,
+ attachment: {
+ url: expect.stringMatching( /^blob:/ ),
+ },
+ } )
+ );
+ expect(
+ registry.select( uploadStore ).getItems()[ 1 ]
+ ).toStrictEqual(
+ expect.objectContaining( {
+ id: expect.any( String ),
+ file: mp4File,
+ sourceFile: mp4File,
+ status: ItemStatus.Processing,
+ attachment: {
+ url: expect.stringMatching( /^blob:/ ),
+ },
+ } )
+ );
+ } );
+ } );
+} );
diff --git a/packages/upload-media/src/store/test/reducer.ts b/packages/upload-media/src/store/test/reducer.ts
new file mode 100644
index 0000000000000..80b92e4b14c3d
--- /dev/null
+++ b/packages/upload-media/src/store/test/reducer.ts
@@ -0,0 +1,279 @@
+/**
+ * Internal dependencies
+ */
+import reducer from '../reducer';
+import {
+ ItemStatus,
+ OperationType,
+ type QueueItem,
+ type State,
+ Type,
+} from '../types';
+
+describe( 'reducer', () => {
+ describe( `${ Type.Add }`, () => {
+ it( 'adds an item to the queue', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.Add,
+ item: {
+ id: '2',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ },
+ ],
+ } );
+ } );
+ } );
+
+ describe( `${ Type.Cancel }`, () => {
+ it( 'removes an item from the queue', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.Cancel,
+ id: '2',
+ error: new Error(),
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ },
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ error: expect.any( Error ),
+ },
+ ],
+ } );
+ } );
+ } );
+
+ describe( `${ Type.Remove }`, () => {
+ it( 'removes an item from the queue', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.Remove,
+ id: '1',
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ },
+ ],
+ } );
+ } );
+ } );
+
+ describe( `${ Type.AddOperations }`, () => {
+ it( 'appends operations to the list', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.AddOperations,
+ id: '1',
+ operations: [ OperationType.Upload ],
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [
+ OperationType.Upload,
+ OperationType.Upload,
+ ],
+ },
+ ],
+ } );
+ } );
+ } );
+
+ describe( `${ Type.OperationStart }`, () => {
+ it( 'marks an item as processing', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ } as QueueItem,
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.OperationStart,
+ id: '2',
+ operation: OperationType.Upload,
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '1',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ },
+ {
+ id: '2',
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ currentOperation: OperationType.Upload,
+ },
+ ],
+ } );
+ } );
+ } );
+
+ describe( `${ Type.OperationFinish }`, () => {
+ it( 'marks an item as processing', () => {
+ const initialState: State = {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ queue: [
+ {
+ id: '1',
+ additionalData: {},
+ attachment: {},
+ status: ItemStatus.Processing,
+ operations: [ OperationType.Upload ],
+ currentOperation: OperationType.Upload,
+ } as QueueItem,
+ ],
+ };
+ const state = reducer( initialState, {
+ type: Type.OperationFinish,
+ id: '1',
+ item: {},
+ } );
+
+ expect( state ).toEqual( {
+ queueStatus: 'active',
+ blobUrls: {},
+ settings: {
+ mediaUpload: expect.any( Function ),
+ },
+ queue: [
+ {
+ id: '1',
+ additionalData: {},
+ attachment: {},
+ status: ItemStatus.Processing,
+ currentOperation: undefined,
+ operations: [],
+ },
+ ],
+ } );
+ } );
+ } );
+} );
diff --git a/packages/upload-media/src/store/test/selectors.ts b/packages/upload-media/src/store/test/selectors.ts
new file mode 100644
index 0000000000000..716b7792ef77a
--- /dev/null
+++ b/packages/upload-media/src/store/test/selectors.ts
@@ -0,0 +1,105 @@
+/**
+ * Internal dependencies
+ */
+import {
+ getItems,
+ isUploading,
+ isUploadingById,
+ isUploadingByUrl,
+} from '../selectors';
+import { ItemStatus, type QueueItem, type State } from '../types';
+
+describe( 'selectors', () => {
+ describe( 'getItems', () => {
+ it( 'should return empty array by default', () => {
+ const state: State = {
+ queue: [],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( getItems( state ) ).toHaveLength( 0 );
+ } );
+ } );
+
+ describe( 'isUploading', () => {
+ it( 'should return true if there are items in the pipeline', () => {
+ const state: State = {
+ queue: [
+ {
+ status: ItemStatus.Processing,
+ },
+ {
+ status: ItemStatus.Processing,
+ },
+ {
+ status: ItemStatus.Paused,
+ },
+ ] as QueueItem[],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( isUploading( state ) ).toBe( true );
+ } );
+ } );
+
+ describe( 'isUploadingByUrl', () => {
+ it( 'should return true if there are items in the pipeline', () => {
+ const state: State = {
+ queue: [
+ {
+ status: ItemStatus.Processing,
+ attachment: {
+ url: 'https://example.com/one.jpeg',
+ },
+ },
+ {
+ status: ItemStatus.Processing,
+ },
+ ] as QueueItem[],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect(
+ isUploadingByUrl( state, 'https://example.com/one.jpeg' )
+ ).toBe( true );
+ expect(
+ isUploadingByUrl( state, 'https://example.com/three.jpeg' )
+ ).toBe( false );
+ } );
+ } );
+
+ describe( 'isUploadingById', () => {
+ it( 'should return true if there are items in the pipeline', () => {
+ const state: State = {
+ queue: [
+ {
+ status: ItemStatus.Processing,
+ attachment: {
+ id: 123,
+ },
+ },
+ ] as QueueItem[],
+ queueStatus: 'paused',
+ blobUrls: {},
+ settings: {
+ mediaUpload: jest.fn(),
+ },
+ };
+
+ expect( isUploadingById( state, 123 ) ).toBe( true );
+ expect( isUploadingById( state, 789 ) ).toBe( false );
+ } );
+ } );
+} );
diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts
new file mode 100644
index 0000000000000..5084e006a2cfa
--- /dev/null
+++ b/packages/upload-media/src/store/types.ts
@@ -0,0 +1,172 @@
+export type QueueItemId = string;
+
+export type QueueStatus = 'active' | 'paused';
+
+export type BatchId = string;
+
+export interface QueueItem {
+ id: QueueItemId;
+ sourceFile: File;
+ file: File;
+ poster?: File;
+ attachment?: Partial< Attachment >;
+ status: ItemStatus;
+ additionalData: AdditionalData;
+ onChange?: OnChangeHandler;
+ onSuccess?: OnSuccessHandler;
+ onError?: OnErrorHandler;
+ onBatchSuccess?: OnBatchSuccessHandler;
+ currentOperation?: OperationType;
+ operations?: Operation[];
+ error?: Error;
+ batchId?: string;
+ sourceUrl?: string;
+ sourceAttachmentId?: number;
+ abortController?: AbortController;
+}
+
+export interface State {
+ queue: QueueItem[];
+ queueStatus: QueueStatus;
+ blobUrls: Record< QueueItemId, string[] >;
+ settings: Settings;
+}
+
+export enum Type {
+ Unknown = 'REDUX_UNKNOWN',
+ Add = 'ADD_ITEM',
+ Prepare = 'PREPARE_ITEM',
+ Cancel = 'CANCEL_ITEM',
+ Remove = 'REMOVE_ITEM',
+ PauseItem = 'PAUSE_ITEM',
+ ResumeItem = 'RESUME_ITEM',
+ PauseQueue = 'PAUSE_QUEUE',
+ ResumeQueue = 'RESUME_QUEUE',
+ OperationStart = 'OPERATION_START',
+ OperationFinish = 'OPERATION_FINISH',
+ AddOperations = 'ADD_OPERATIONS',
+ CacheBlobUrl = 'CACHE_BLOB_URL',
+ RevokeBlobUrls = 'REVOKE_BLOB_URLS',
+ UpdateSettings = 'UPDATE_SETTINGS',
+}
+
+type Action< T = Type, Payload = Record< string, unknown > > = {
+ type: T;
+} & Payload;
+
+export type UnknownAction = Action< Type.Unknown >;
+export type AddAction = Action<
+ Type.Add,
+ {
+ item: Omit< QueueItem, 'operations' > &
+ Partial< Pick< QueueItem, 'operations' > >;
+ }
+>;
+export type OperationStartAction = Action<
+ Type.OperationStart,
+ { id: QueueItemId; operation: OperationType }
+>;
+export type OperationFinishAction = Action<
+ Type.OperationFinish,
+ {
+ id: QueueItemId;
+ item: Partial< QueueItem >;
+ }
+>;
+export type AddOperationsAction = Action<
+ Type.AddOperations,
+ { id: QueueItemId; operations: Operation[] }
+>;
+export type CancelAction = Action<
+ Type.Cancel,
+ { id: QueueItemId; error: Error }
+>;
+export type PauseItemAction = Action< Type.PauseItem, { id: QueueItemId } >;
+export type ResumeItemAction = Action< Type.ResumeItem, { id: QueueItemId } >;
+export type PauseQueueAction = Action< Type.PauseQueue >;
+export type ResumeQueueAction = Action< Type.ResumeQueue >;
+export type RemoveAction = Action< Type.Remove, { id: QueueItemId } >;
+export type CacheBlobUrlAction = Action<
+ Type.CacheBlobUrl,
+ { id: QueueItemId; blobUrl: string }
+>;
+export type RevokeBlobUrlsAction = Action<
+ Type.RevokeBlobUrls,
+ { id: QueueItemId }
+>;
+export type UpdateSettingsAction = Action<
+ Type.UpdateSettings,
+ { settings: Partial< Settings > }
+>;
+
+interface UploadMediaArgs {
+ // Additional data to include in the request.
+ additionalData?: AdditionalData;
+ // Array with the types of media that can be uploaded, if unset all types are allowed.
+ allowedTypes?: string[];
+ // List of files.
+ filesList: File[];
+ // Maximum upload size in bytes allowed for the site.
+ maxUploadFileSize?: number;
+ // Function called when an error happens.
+ onError?: OnErrorHandler;
+ // Function called each time a file or a temporary representation of the file is available.
+ onFileChange?: OnChangeHandler;
+ // Function called once a file has completely finished uploading, including thumbnails.
+ onSuccess?: OnSuccessHandler;
+ // List of allowed mime types and file extensions.
+ wpAllowedMimeTypes?: Record< string, string > | null;
+ // Abort signal.
+ signal?: AbortSignal;
+}
+
+export interface Settings {
+ // Function for uploading files to the server.
+ mediaUpload: ( args: UploadMediaArgs ) => void;
+ // List of allowed mime types and file extensions.
+ allowedMimeTypes?: Record< string, string > | null;
+ // Maximum upload file size
+ maxUploadFileSize?: number;
+}
+
+// Must match the Attachment type from the media-utils package.
+export interface Attachment {
+ id: number;
+ alt: string;
+ caption: string;
+ title: string;
+ url: string;
+ filename: string | null;
+ filesize: number | null;
+ media_type: 'image' | 'file';
+ mime_type: string;
+ featured_media?: number;
+ missing_image_sizes?: string[];
+ poster?: string;
+}
+
+export type OnChangeHandler = ( attachments: Partial< Attachment >[] ) => void;
+export type OnSuccessHandler = ( attachments: Partial< Attachment >[] ) => void;
+export type OnErrorHandler = ( error: Error ) => void;
+export type OnBatchSuccessHandler = () => void;
+
+export enum ItemStatus {
+ Processing = 'PROCESSING',
+ Paused = 'PAUSED',
+}
+
+export enum OperationType {
+ Prepare = 'PREPARE',
+ Upload = 'UPLOAD',
+}
+
+export interface OperationArgs {}
+
+type OperationWithArgs< T extends keyof OperationArgs = keyof OperationArgs > =
+ [ T, OperationArgs[ T ] ];
+
+export type Operation = OperationType | OperationWithArgs;
+
+export type AdditionalData = Record< string, unknown >;
+
+export type ImageFormat = 'jpeg' | 'webp' | 'avif' | 'png' | 'gif';
diff --git a/packages/upload-media/src/stub-file.ts b/packages/upload-media/src/stub-file.ts
new file mode 100644
index 0000000000000..f308c0d48b6f4
--- /dev/null
+++ b/packages/upload-media/src/stub-file.ts
@@ -0,0 +1,5 @@
+export class StubFile extends File {
+ constructor( fileName = 'stub-file' ) {
+ super( [], fileName );
+ }
+}
diff --git a/packages/upload-media/src/test/get-file-basename.ts b/packages/upload-media/src/test/get-file-basename.ts
new file mode 100644
index 0000000000000..6bf968a764346
--- /dev/null
+++ b/packages/upload-media/src/test/get-file-basename.ts
@@ -0,0 +1,15 @@
+/**
+ * Internal dependencies
+ */
+import { getFileBasename } from '../utils';
+
+describe( 'getFileBasename', () => {
+ it.each( [
+ [ 'my-video.mp4', 'my-video' ],
+ [ 'my.video.mp4', 'my.video' ],
+ [ 'my-video', 'my-video' ],
+ [ '', '' ],
+ ] )( 'for file name %s returns basename %s', ( fileName, baseName ) => {
+ expect( getFileBasename( fileName ) ).toStrictEqual( baseName );
+ } );
+} );
diff --git a/packages/upload-media/src/test/get-file-extension.ts b/packages/upload-media/src/test/get-file-extension.ts
new file mode 100644
index 0000000000000..b26c4571be73f
--- /dev/null
+++ b/packages/upload-media/src/test/get-file-extension.ts
@@ -0,0 +1,15 @@
+/**
+ * Internal dependencies
+ */
+import { getFileExtension } from '../utils';
+
+describe( 'getFileExtension', () => {
+ it.each( [
+ [ 'my-video.mp4', 'mp4' ],
+ [ 'my.video.mp4', 'mp4' ],
+ [ 'my-video', null ],
+ [ '', null ],
+ ] )( 'for file name %s returns extension %s', ( fileName, baseName ) => {
+ expect( getFileExtension( fileName ) ).toStrictEqual( baseName );
+ } );
+} );
diff --git a/packages/upload-media/src/test/get-file-name-from-url.ts b/packages/upload-media/src/test/get-file-name-from-url.ts
new file mode 100644
index 0000000000000..6e2d497472e76
--- /dev/null
+++ b/packages/upload-media/src/test/get-file-name-from-url.ts
@@ -0,0 +1,14 @@
+/**
+ * Internal dependencies
+ */
+import { getFileNameFromUrl } from '../utils';
+
+describe( 'getFileNameFromUrl', () => {
+ it.each( [
+ [ 'https://example.com/', 'unnamed' ],
+ [ 'https://example.com/photo.jpeg', 'photo.jpeg' ],
+ [ 'https://example.com/path/to/video.mp4', 'video.mp4' ],
+ ] )( 'for %s returns %s', ( url, fileName ) => {
+ expect( getFileNameFromUrl( url ) ).toBe( fileName );
+ } );
+} );
diff --git a/packages/upload-media/src/test/get-mime-types-array.ts b/packages/upload-media/src/test/get-mime-types-array.ts
new file mode 100644
index 0000000000000..156955373bd0d
--- /dev/null
+++ b/packages/upload-media/src/test/get-mime-types-array.ts
@@ -0,0 +1,47 @@
+/**
+ * Internal dependencies
+ */
+import { getMimeTypesArray } from '../get-mime-types-array';
+
+describe( 'getMimeTypesArray', () => {
+ it( 'should return null if it is "falsy" e.g: undefined or null', () => {
+ expect( getMimeTypesArray( null ) ).toEqual( null );
+ expect( getMimeTypesArray( undefined ) ).toEqual( null );
+ } );
+
+ it( 'should return an empty array if an empty object is passed', () => {
+ expect( getMimeTypesArray( {} ) ).toEqual( [] );
+ } );
+
+ it( 'should return the type plus a new mime type with type and subtype with the extension if a type is passed', () => {
+ expect( getMimeTypesArray( { ext: 'chicken' } ) ).toEqual( [
+ 'chicken',
+ 'chicken/ext',
+ ] );
+ } );
+
+ it( 'should return the mime type passed and a new mime type with type and the extension as subtype', () => {
+ expect( getMimeTypesArray( { ext: 'chicken/ribs' } ) ).toEqual( [
+ 'chicken/ribs',
+ 'chicken/ext',
+ ] );
+ } );
+
+ it( 'should return the mime type passed and an additional mime type per extension supported', () => {
+ expect( getMimeTypesArray( { 'jpg|jpeg|jpe': 'image/jpeg' } ) ).toEqual(
+ [ 'image/jpeg', 'image/jpg', 'image/jpeg', 'image/jpe' ]
+ );
+ } );
+
+ it( 'should handle multiple mime types', () => {
+ expect(
+ getMimeTypesArray( { 'ext|aaa': 'chicken/ribs', aaa: 'bbb' } )
+ ).toEqual( [
+ 'chicken/ribs',
+ 'chicken/ext',
+ 'chicken/aaa',
+ 'bbb',
+ 'bbb/aaa',
+ ] );
+ } );
+} );
diff --git a/packages/upload-media/src/test/image-file.ts b/packages/upload-media/src/test/image-file.ts
new file mode 100644
index 0000000000000..e48ae2df6ebce
--- /dev/null
+++ b/packages/upload-media/src/test/image-file.ts
@@ -0,0 +1,15 @@
+/**
+ * Internal dependencies
+ */
+import { ImageFile } from '../image-file';
+
+describe( 'ImageFile', () => {
+ it( 'returns whether the file was resizes', () => {
+ const file = new window.File( [ 'fake_file' ], 'test.jpeg', {
+ type: 'image/jpeg',
+ } );
+
+ const image = new ImageFile( file, 1000, 1000, 2000, 200 );
+ expect( image.wasResized ).toBe( true );
+ } );
+} );
diff --git a/packages/upload-media/src/test/upload-error.ts b/packages/upload-media/src/test/upload-error.ts
new file mode 100644
index 0000000000000..4d5f025ed8cf3
--- /dev/null
+++ b/packages/upload-media/src/test/upload-error.ts
@@ -0,0 +1,24 @@
+/**
+ * Internal dependencies
+ */
+import { UploadError } from '../upload-error';
+
+describe( 'UploadError', () => {
+ it( 'holds error code and file name', () => {
+ const file = new File( [], 'example.jpg', {
+ lastModified: 1234567891,
+ type: 'image/jpeg',
+ } );
+
+ const error = new UploadError( {
+ code: 'some_error',
+ message: 'An error occurred',
+ file,
+ } );
+
+ expect( error ).toStrictEqual( expect.any( Error ) );
+ expect( error.code ).toBe( 'some_error' );
+ expect( error.message ).toBe( 'An error occurred' );
+ expect( error.file ).toBe( file );
+ } );
+} );
diff --git a/packages/upload-media/src/test/validate-file-size.ts b/packages/upload-media/src/test/validate-file-size.ts
new file mode 100644
index 0000000000000..31d6af0e7e4a5
--- /dev/null
+++ b/packages/upload-media/src/test/validate-file-size.ts
@@ -0,0 +1,70 @@
+/**
+ * Internal dependencies
+ */
+import { validateFileSize } from '../validate-file-size';
+import { UploadError } from '../upload-error';
+
+const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', {
+ type: 'image/jpeg',
+} );
+
+const emptyFile = new window.File( [], 'test.jpeg', {
+ type: 'image/jpeg',
+} );
+
+describe( 'validateFileSize', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should error if the file is empty', () => {
+ expect( () => {
+ validateFileSize( emptyFile );
+ } ).toThrow(
+ new UploadError( {
+ code: 'EMPTY_FILE',
+ message: 'test.jpeg: This file is empty.',
+ file: imageFile,
+ } )
+ );
+ } );
+
+ it( 'should error if the file is is greater than the maximum', () => {
+ expect( () => {
+ validateFileSize( imageFile, 2 );
+ } ).toThrow(
+ new UploadError( {
+ code: 'SIZE_ABOVE_LIMIT',
+ message:
+ 'test.jpeg: This file exceeds the maximum upload size for this site.',
+ file: imageFile,
+ } )
+ );
+ } );
+
+ it( 'should not error if the file is below the limit', () => {
+ expect( () => {
+ validateFileSize( imageFile, 100 );
+ } ).not.toThrow(
+ new UploadError( {
+ code: 'SIZE_ABOVE_LIMIT',
+ message:
+ 'test.jpeg: This file exceeds the maximum upload size for this site.',
+ file: imageFile,
+ } )
+ );
+ } );
+
+ it( 'should not error if there is no limit', () => {
+ expect( () => {
+ validateFileSize( imageFile );
+ } ).not.toThrow(
+ new UploadError( {
+ code: 'SIZE_ABOVE_LIMIT',
+ message:
+ 'test.jpeg: This file exceeds the maximum upload size for this site.',
+ file: imageFile,
+ } )
+ );
+ } );
+} );
diff --git a/packages/upload-media/src/test/validate-mime-type-for-user.ts b/packages/upload-media/src/test/validate-mime-type-for-user.ts
new file mode 100644
index 0000000000000..d256656686214
--- /dev/null
+++ b/packages/upload-media/src/test/validate-mime-type-for-user.ts
@@ -0,0 +1,37 @@
+/**
+ * Internal dependencies
+ */
+import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
+import { UploadError } from '../upload-error';
+
+const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', {
+ type: 'image/jpeg',
+} );
+
+describe( 'validateMimeTypeForUser', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should not error if wpAllowedMimeTypes is null or missing', async () => {
+ expect( () => {
+ validateMimeTypeForUser( imageFile );
+ } ).not.toThrow();
+ expect( () => {
+ validateMimeTypeForUser( imageFile, null );
+ } ).not.toThrow();
+ } );
+
+ it( 'should error if file type is not allowed for user', async () => {
+ expect( () => {
+ validateMimeTypeForUser( imageFile, { aac: 'audio/aac' } );
+ } ).toThrow(
+ new UploadError( {
+ code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER',
+ message:
+ 'test.jpeg: Sorry, you are not allowed to upload this file type.',
+ file: imageFile,
+ } )
+ );
+ } );
+} );
diff --git a/packages/upload-media/src/test/validate-mime-type.ts b/packages/upload-media/src/test/validate-mime-type.ts
new file mode 100644
index 0000000000000..a83cdcefe5f99
--- /dev/null
+++ b/packages/upload-media/src/test/validate-mime-type.ts
@@ -0,0 +1,57 @@
+/**
+ * Internal dependencies
+ */
+import { validateMimeType } from '../validate-mime-type';
+import { UploadError } from '../upload-error';
+
+const xmlFile = new window.File( [ 'fake_file' ], 'test.xml', {
+ type: 'text/xml',
+} );
+const imageFile = new window.File( [ 'fake_file' ], 'test.jpeg', {
+ type: 'image/jpeg',
+} );
+
+describe( 'validateMimeType', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'should error if allowedTypes contains a partial mime type and the validation fails', async () => {
+ expect( () => {
+ validateMimeType( xmlFile, [ 'image' ] );
+ } ).toThrow(
+ new UploadError( {
+ code: 'MIME_TYPE_NOT_SUPPORTED',
+ message:
+ 'test.xml: Sorry, this file type is not supported here.',
+ file: xmlFile,
+ } )
+ );
+ } );
+
+ it( 'should error if allowedTypes contains a complete mime type and the validation fails', async () => {
+ expect( () => {
+ validateMimeType( imageFile, [ 'image/gif' ] );
+ } ).toThrow(
+ new UploadError( {
+ code: 'MIME_TYPE_NOT_SUPPORTED',
+ message:
+ 'test.jpeg: Sorry, this file type is not supported here.',
+ file: xmlFile,
+ } )
+ );
+ } );
+
+ it( 'should error if allowedTypes contains multiple types and the validation fails', async () => {
+ expect( () => {
+ validateMimeType( xmlFile, [ 'video', 'image' ] );
+ } ).toThrow(
+ new UploadError( {
+ code: 'MIME_TYPE_NOT_SUPPORTED',
+ message:
+ 'test.xml: Sorry, this file type is not supported here.',
+ file: xmlFile,
+ } )
+ );
+ } );
+} );
diff --git a/packages/upload-media/src/upload-error.ts b/packages/upload-media/src/upload-error.ts
new file mode 100644
index 0000000000000..d712e9dcdb696
--- /dev/null
+++ b/packages/upload-media/src/upload-error.ts
@@ -0,0 +1,26 @@
+interface UploadErrorArgs {
+ code: string;
+ message: string;
+ file: File;
+ cause?: Error;
+}
+
+/**
+ * MediaError class.
+ *
+ * Small wrapper around the `Error` class
+ * to hold an error code and a reference to a file object.
+ */
+export class UploadError extends Error {
+ code: string;
+ file: File;
+
+ constructor( { code, message, file, cause }: UploadErrorArgs ) {
+ super( message, { cause } );
+
+ Object.setPrototypeOf( this, new.target.prototype );
+
+ this.code = code;
+ this.file = file;
+ }
+}
diff --git a/packages/upload-media/src/utils.ts b/packages/upload-media/src/utils.ts
new file mode 100644
index 0000000000000..3950ec0388792
--- /dev/null
+++ b/packages/upload-media/src/utils.ts
@@ -0,0 +1,90 @@
+/**
+ * WordPress dependencies
+ */
+import { getFilename } from '@wordpress/url';
+import { _x } from '@wordpress/i18n';
+
+/**
+ * Converts a Blob to a File with a default name like "image.png".
+ *
+ * If it is already a File object, it is returned unchanged.
+ *
+ * @param fileOrBlob Blob object.
+ * @return File object.
+ */
+export function convertBlobToFile( fileOrBlob: Blob | File ): File {
+ if ( fileOrBlob instanceof File ) {
+ return fileOrBlob;
+ }
+
+ // Extension is only an approximation.
+ // The server will override it if incorrect.
+ const ext = fileOrBlob.type.split( '/' )[ 1 ];
+ const mediaType =
+ 'application/pdf' === fileOrBlob.type
+ ? 'document'
+ : fileOrBlob.type.split( '/' )[ 0 ];
+ return new File( [ fileOrBlob ], `${ mediaType }.${ ext }`, {
+ type: fileOrBlob.type,
+ } );
+}
+
+/**
+ * Renames a given file and returns a new file.
+ *
+ * Copies over the last modified time.
+ *
+ * @param file File object.
+ * @param name File name.
+ * @return Renamed file object.
+ */
+export function renameFile( file: File, name: string ): File {
+ return new File( [ file ], name, {
+ type: file.type,
+ lastModified: file.lastModified,
+ } );
+}
+
+/**
+ * Clones a given file object.
+ *
+ * @param file File object.
+ * @return New file object.
+ */
+export function cloneFile( file: File ): File {
+ return renameFile( file, file.name );
+}
+
+/**
+ * Returns the file extension from a given file name or URL.
+ *
+ * @param file File URL.
+ * @return File extension or null if it does not have one.
+ */
+export function getFileExtension( file: string ): string | null {
+ return file.includes( '.' ) ? file.split( '.' ).pop() || null : null;
+}
+
+/**
+ * Returns file basename without extension.
+ *
+ * For example, turns "my-awesome-file.jpeg" into "my-awesome-file".
+ *
+ * @param name File name.
+ * @return File basename.
+ */
+export function getFileBasename( name: string ): string {
+ return name.includes( '.' )
+ ? name.split( '.' ).slice( 0, -1 ).join( '.' )
+ : name;
+}
+
+/**
+ * Returns the file name including extension from a URL.
+ *
+ * @param url File URL.
+ * @return File name.
+ */
+export function getFileNameFromUrl( url: string ) {
+ return getFilename( url ) || _x( 'unnamed', 'file name' );
+}
diff --git a/packages/upload-media/src/validate-file-size.ts b/packages/upload-media/src/validate-file-size.ts
new file mode 100644
index 0000000000000..cc34462b268dd
--- /dev/null
+++ b/packages/upload-media/src/validate-file-size.ts
@@ -0,0 +1,44 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { UploadError } from './upload-error';
+
+/**
+ * Verifies whether the file is within the file upload size limits for the site.
+ *
+ * @param file File object.
+ * @param maxUploadFileSize Maximum upload size in bytes allowed for the site.
+ */
+export function validateFileSize( file: File, maxUploadFileSize?: number ) {
+ // Don't allow empty files to be uploaded.
+ if ( file.size <= 0 ) {
+ throw new UploadError( {
+ code: 'EMPTY_FILE',
+ message: sprintf(
+ // translators: %s: file name.
+ __( '%s: This file is empty.' ),
+ file.name
+ ),
+ file,
+ } );
+ }
+
+ if ( maxUploadFileSize && file.size > maxUploadFileSize ) {
+ throw new UploadError( {
+ code: 'SIZE_ABOVE_LIMIT',
+ message: sprintf(
+ // translators: %s: file name.
+ __(
+ '%s: This file exceeds the maximum upload size for this site.'
+ ),
+ file.name
+ ),
+ file,
+ } );
+ }
+}
diff --git a/packages/upload-media/src/validate-mime-type-for-user.ts b/packages/upload-media/src/validate-mime-type-for-user.ts
new file mode 100644
index 0000000000000..858c583561978
--- /dev/null
+++ b/packages/upload-media/src/validate-mime-type-for-user.ts
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { UploadError } from './upload-error';
+import { getMimeTypesArray } from './get-mime-types-array';
+
+/**
+ * Verifies if the user is allowed to upload this mime type.
+ *
+ * @param file File object.
+ * @param wpAllowedMimeTypes List of allowed mime types and file extensions.
+ */
+export function validateMimeTypeForUser(
+ file: File,
+ wpAllowedMimeTypes?: Record< string, string > | null
+) {
+ // Allowed types for the current WP_User.
+ const allowedMimeTypesForUser = getMimeTypesArray( wpAllowedMimeTypes );
+
+ if ( ! allowedMimeTypesForUser ) {
+ return;
+ }
+
+ const isAllowedMimeTypeForUser = allowedMimeTypesForUser.includes(
+ file.type
+ );
+
+ if ( file.type && ! isAllowedMimeTypeForUser ) {
+ throw new UploadError( {
+ code: 'MIME_TYPE_NOT_ALLOWED_FOR_USER',
+ message: sprintf(
+ // translators: %s: file name.
+ __(
+ '%s: Sorry, you are not allowed to upload this file type.'
+ ),
+ file.name
+ ),
+ file,
+ } );
+ }
+}
diff --git a/packages/upload-media/src/validate-mime-type.ts b/packages/upload-media/src/validate-mime-type.ts
new file mode 100644
index 0000000000000..2d99455d7b60f
--- /dev/null
+++ b/packages/upload-media/src/validate-mime-type.ts
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { UploadError } from './upload-error';
+
+/**
+ * Verifies if the caller (e.g. a block) supports this mime type.
+ *
+ * @param file File object.
+ * @param allowedTypes List of allowed mime types.
+ */
+export function validateMimeType( file: File, allowedTypes?: string[] ) {
+ if ( ! allowedTypes ) {
+ return;
+ }
+
+ // Allowed type specified by consumer.
+ const isAllowedType = allowedTypes.some( ( allowedType ) => {
+ // If a complete mimetype is specified verify if it matches exactly the mime type of the file.
+ if ( allowedType.includes( '/' ) ) {
+ return allowedType === file.type;
+ }
+ // Otherwise a general mime type is used, and we should verify if the file mimetype starts with it.
+ return file.type.startsWith( `${ allowedType }/` );
+ } );
+
+ if ( file.type && ! isAllowedType ) {
+ throw new UploadError( {
+ code: 'MIME_TYPE_NOT_SUPPORTED',
+ message: sprintf(
+ // translators: %s: file name.
+ __( '%s: Sorry, this file type is not supported here.' ),
+ file.name
+ ),
+ file,
+ } );
+ }
+}
diff --git a/packages/upload-media/tsconfig.json b/packages/upload-media/tsconfig.json
new file mode 100644
index 0000000000000..b0bc834698905
--- /dev/null
+++ b/packages/upload-media/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "declarationDir": "build-types",
+ "types": [ "gutenberg-env" ]
+ },
+ "include": [ "src/**/*" ],
+ "references": [
+ { "path": "../api-fetch" },
+ { "path": "../blob" },
+ { "path": "../compose" },
+ { "path": "../data" },
+ { "path": "../element" },
+ { "path": "../i18n" },
+ { "path": "../private-apis" },
+ { "path": "../url" }
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 1010054ea512e..93d0bd976dd00 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -55,6 +55,7 @@
{ "path": "packages/sync" },
{ "path": "packages/token-list" },
{ "path": "packages/undo-manager" },
+ { "path": "packages/upload-media" },
{ "path": "packages/url" },
{ "path": "packages/vips" },
{ "path": "packages/warning" },
From 2cdd37d45d9eea93377314acfa803786d9049dec Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Mon, 16 Dec 2024 11:17:15 +0100
Subject: [PATCH 42/66] Menu: more granular sub-components (#67422)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* MenuItem: add render and store props
* Extract sub-components: popover, trigger button, submenu trigger item
* Unit tests
* CHANGELOG
* Add more memory to node on CI
* Refactor block bindings panel menu (#67633)
Co-authored-by: ciampo
* Storybook (#67632)
* Refactor dataviews item actions menu (#67636)
* Refactor dataviews view config menu (#67637)
* Refactor global styles shadows edit panel menu (#67641)
* Refactor global styles font size menus (#67642)
* Refactor "Add filter" dataviews menu (#67634)
* Menu granular subcomponents: Refactor dataviews list layout actions menu (#67639)
Co-authored-by: ciampo
Co-authored-by: tyxla
Co-authored-by: oandregal
* Menu granular subcomponents: Refactor dataviews table layout header menu (#67640)
Co-authored-by: ciampo
Co-authored-by: tyxla
Co-authored-by: oandregal
* Menu granular subcomponents: Refactor post actions menu (#67645)
Co-authored-by: ciampo
Co-authored-by: tyxla
* Better comments for submenu trigger store
* Typo
* Remove unnecessary MenuSubmenuTriggerItemProps type
* Don't break the rules of hooks 🪝
* Move CHANGELOG entry to unreleased section
* Add explicit MenuProps to improve TS performance
* Remove node memory settings
---------
Co-authored-by: ciampo
Co-authored-by: tyxla
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: oandregal
---
.../block-editor/src/hooks/block-bindings.js | 31 +-
packages/components/CHANGELOG.md | 1 +
packages/components/src/menu/index.tsx | 225 ++-----
packages/components/src/menu/item.tsx | 10 +-
packages/components/src/menu/popover.tsx | 103 +++
.../src/menu/stories/index.story.tsx | 619 ++++++++++--------
.../src/menu/submenu-trigger-item.tsx | 61 ++
packages/components/src/menu/test/index.tsx | 448 ++++++++-----
.../components/src/menu/trigger-button.tsx | 46 ++
packages/components/src/menu/types.ts | 79 ++-
.../dataviews-filters/add-filter.tsx | 82 +--
.../components/dataviews-filters/index.tsx | 2 +-
.../dataviews-item-actions/index.tsx | 38 +-
.../dataviews-view-config/index.tsx | 93 +--
.../src/dataviews-layouts/list/index.tsx | 54 +-
.../table/column-header-menu.tsx | 299 ++++-----
.../global-styles/font-sizes/font-size.js | 47 +-
.../global-styles/font-sizes/font-sizes.js | 48 +-
.../global-styles/shadows-edit-panel.js | 59 +-
.../src/components/post-actions/index.js | 36 +-
20 files changed, 1384 insertions(+), 997 deletions(-)
create mode 100644 packages/components/src/menu/popover.tsx
create mode 100644 packages/components/src/menu/submenu-trigger-item.tsx
create mode 100644 packages/components/src/menu/trigger-button.tsx
diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js
index 2dab67d629332..11e17aba3b30d 100644
--- a/packages/block-editor/src/hooks/block-bindings.js
+++ b/packages/block-editor/src/hooks/block-bindings.js
@@ -51,7 +51,7 @@ const useToolsPanelDropdownMenuProps = () => {
: {};
};
-function BlockBindingsPanelDropdown( { fieldsList, attribute, binding } ) {
+function BlockBindingsPanelMenuContent( { fieldsList, attribute, binding } ) {
const { clientId } = useBlockEditContext();
const registeredSources = getBlockBindingsSources();
const { updateBlockBindings } = useBlockBindingsUtils();
@@ -179,22 +179,21 @@ function EditableBlockBindingsPanelItems( {
placement={
isMobile ? 'bottom-start' : 'left-start'
}
- gutter={ isMobile ? 8 : 36 }
- trigger={
- -
-
-
- }
>
-
+ }>
+
+
+
+
+
);
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index fef1769c19b0f..7b5ec64bd44ca 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -19,6 +19,7 @@
### Experimental
- Add new `Badge` component ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).
+- `Menu`: refactor to more granular sub-components ([#67422](https://github.com/WordPress/gutenberg/pull/67422)).
### Internal
diff --git a/packages/components/src/menu/index.tsx b/packages/components/src/menu/index.tsx
index 9886f32482321..2e0fc91cfbc34 100644
--- a/packages/components/src/menu/index.tsx
+++ b/packages/components/src/menu/index.tsx
@@ -6,23 +6,14 @@ import * as Ariakit from '@ariakit/react';
/**
* WordPress dependencies
*/
-import {
- useContext,
- useMemo,
- cloneElement,
- isValidElement,
- useCallback,
-} from '@wordpress/element';
-import { isRTL } from '@wordpress/i18n';
-import { chevronRightSmall } from '@wordpress/icons';
+import { useContext, useMemo } from '@wordpress/element';
+import { isRTL as isRTLFn } from '@wordpress/i18n';
/**
* Internal dependencies
*/
-import { useContextSystem, contextConnect } from '../context';
-import type { WordPressComponentProps } from '../context';
+import { useContextSystem, contextConnectWithoutRef } from '../context';
import type { MenuContext as MenuContextType, MenuProps } from './types';
-import * as Styled from './styles';
import { MenuContext } from './context';
import { MenuItem } from './item';
import { MenuCheckboxItem } from './checkbox-item';
@@ -32,49 +23,36 @@ import { MenuGroupLabel } from './group-label';
import { MenuSeparator } from './separator';
import { MenuItemLabel } from './item-label';
import { MenuItemHelpText } from './item-help-text';
+import { MenuTriggerButton } from './trigger-button';
+import { MenuSubmenuTriggerItem } from './submenu-trigger-item';
+import { MenuPopover } from './popover';
-const UnconnectedMenu = (
- props: WordPressComponentProps< MenuProps, 'div', false >,
- ref: React.ForwardedRef< HTMLDivElement >
-) => {
+const UnconnectedMenu = ( props: MenuProps ) => {
const {
- // Store props
- open,
+ children,
defaultOpen = false,
+ open,
onOpenChange,
placement,
- // Menu trigger props
- trigger,
-
- // Menu props
- gutter,
- children,
- shift,
- modal = true,
-
// From internal components context
variant,
-
- // Rest
- ...otherProps
- } = useContextSystem< typeof props & Pick< MenuContextType, 'variant' > >(
- props,
- 'Menu'
- );
+ } = useContextSystem<
+ // @ts-expect-error TODO: missing 'className' in MenuProps
+ typeof props & Pick< MenuContextType, 'variant' >
+ >( props, 'Menu' );
const parentContext = useContext( MenuContext );
- const computedDirection = isRTL() ? 'rtl' : 'ltr';
+ const rtl = isRTLFn();
// If an explicit value for the `placement` prop is not passed,
// apply a default placement of `bottom-start` for the root menu popover,
// and of `right-start` for nested menu popovers.
let computedPlacement =
- props.placement ??
- ( parentContext?.store ? 'right-start' : 'bottom-start' );
+ placement ?? ( parentContext?.store ? 'right-start' : 'bottom-start' );
// Swap left/right in case of RTL direction
- if ( computedDirection === 'rtl' ) {
+ if ( rtl ) {
if ( /right/.test( computedPlacement ) ) {
computedPlacement = computedPlacement.replace(
'right',
@@ -97,7 +75,7 @@ const UnconnectedMenu = (
setOpen( willBeOpen ) {
onOpenChange?.( willBeOpen );
},
- rtl: computedDirection === 'rtl',
+ rtl,
} );
const contextValue = useMemo(
@@ -105,134 +83,53 @@ const UnconnectedMenu = (
[ menuStore, variant ]
);
- // Extract the side from the applied placement — useful for animations.
- // Using `currentPlacement` instead of `placement` to make sure that we
- // use the final computed placement (including "flips" etc).
- const appliedPlacementSide = Ariakit.useStoreState(
- menuStore,
- 'currentPlacement'
- ).split( '-' )[ 0 ];
-
- if (
- menuStore.parent &&
- ! ( isValidElement( trigger ) && MenuItem === trigger.type )
- ) {
- // eslint-disable-next-line no-console
- console.warn(
- 'For nested Menus, the `trigger` should always be a `MenuItem`.'
- );
- }
-
- const hideOnEscape = useCallback(
- ( event: React.KeyboardEvent< Element > ) => {
- // Pressing Escape can cause unexpected consequences (ie. exiting
- // full screen mode on MacOs, close parent modals...).
- event.preventDefault();
- // Returning `true` causes the menu to hide.
- return true;
- },
- []
- );
-
- const wrapperProps = useMemo(
- () => ( {
- dir: computedDirection,
- style: {
- direction:
- computedDirection as React.CSSProperties[ 'direction' ],
- },
- } ),
- [ computedDirection ]
- );
-
return (
- <>
- { /* Menu trigger */ }
-
- { trigger.props.suffix }
-
- >
- ),
- } )
- : trigger
- }
- />
-
- { /* Menu popover */ }
- (
- // Two wrappers are needed for the entry animation, where the menu
- // container scales with a different factor than its contents.
- // The {...renderProps} are passed to the inner wrapper, so that the
- // menu element is the direct parent of the menu item elements.
-
-
-
- ) }
- >
-
- { children }
-
-
- >
+
+ { children }
+
);
};
-export const Menu = Object.assign( contextConnect( UnconnectedMenu, 'Menu' ), {
- Context: Object.assign( MenuContext, {
- displayName: 'Menu.Context',
- } ),
- Item: Object.assign( MenuItem, {
- displayName: 'Menu.Item',
- } ),
- RadioItem: Object.assign( MenuRadioItem, {
- displayName: 'Menu.RadioItem',
- } ),
- CheckboxItem: Object.assign( MenuCheckboxItem, {
- displayName: 'Menu.CheckboxItem',
- } ),
- Group: Object.assign( MenuGroup, {
- displayName: 'Menu.Group',
- } ),
- GroupLabel: Object.assign( MenuGroupLabel, {
- displayName: 'Menu.GroupLabel',
- } ),
- Separator: Object.assign( MenuSeparator, {
- displayName: 'Menu.Separator',
- } ),
- ItemLabel: Object.assign( MenuItemLabel, {
- displayName: 'Menu.ItemLabel',
- } ),
- ItemHelpText: Object.assign( MenuItemHelpText, {
- displayName: 'Menu.ItemHelpText',
- } ),
-} );
+export const Menu = Object.assign(
+ contextConnectWithoutRef( UnconnectedMenu, 'Menu' ),
+ {
+ Context: Object.assign( MenuContext, {
+ displayName: 'Menu.Context',
+ } ),
+ Item: Object.assign( MenuItem, {
+ displayName: 'Menu.Item',
+ } ),
+ RadioItem: Object.assign( MenuRadioItem, {
+ displayName: 'Menu.RadioItem',
+ } ),
+ CheckboxItem: Object.assign( MenuCheckboxItem, {
+ displayName: 'Menu.CheckboxItem',
+ } ),
+ Group: Object.assign( MenuGroup, {
+ displayName: 'Menu.Group',
+ } ),
+ GroupLabel: Object.assign( MenuGroupLabel, {
+ displayName: 'Menu.GroupLabel',
+ } ),
+ Separator: Object.assign( MenuSeparator, {
+ displayName: 'Menu.Separator',
+ } ),
+ ItemLabel: Object.assign( MenuItemLabel, {
+ displayName: 'Menu.ItemLabel',
+ } ),
+ ItemHelpText: Object.assign( MenuItemHelpText, {
+ displayName: 'Menu.ItemHelpText',
+ } ),
+ Popover: Object.assign( MenuPopover, {
+ displayName: 'Menu.Popover',
+ } ),
+ TriggerButton: Object.assign( MenuTriggerButton, {
+ displayName: 'Menu.TriggerButton',
+ } ),
+ SubmenuTriggerItem: Object.assign( MenuSubmenuTriggerItem, {
+ displayName: 'Menu.SubmenuTriggerItem',
+ } ),
+ }
+);
export default Menu;
diff --git a/packages/components/src/menu/item.tsx b/packages/components/src/menu/item.tsx
index 6d09bdf3d0f59..84ff050bcc223 100644
--- a/packages/components/src/menu/item.tsx
+++ b/packages/components/src/menu/item.tsx
@@ -15,7 +15,7 @@ export const MenuItem = forwardRef<
HTMLDivElement,
WordPressComponentProps< MenuItemProps, 'div', false >
>( function MenuItem(
- { prefix, suffix, children, hideOnClick = true, ...props },
+ { prefix, suffix, children, hideOnClick = true, store, ...props },
ref
) {
const menuContext = useContext( MenuContext );
@@ -26,13 +26,19 @@ export const MenuItem = forwardRef<
);
}
+ // In most cases, the menu store will be retrieved from context (ie. the store
+ // created by the top-level menu component). But in rare cases (ie.
+ // `Menu.SubmenuTriggerItem`), the context store wouldn't be correct. This is
+ // why the component accepts a `store` prop to override the context store.
+ const computedStore = store ?? menuContext.store;
+
return (
{ prefix }
diff --git a/packages/components/src/menu/popover.tsx b/packages/components/src/menu/popover.tsx
new file mode 100644
index 0000000000000..19972a31027ce
--- /dev/null
+++ b/packages/components/src/menu/popover.tsx
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ useContext,
+ useMemo,
+ forwardRef,
+ useCallback,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { WordPressComponentProps } from '../context';
+import type { MenuPopoverProps } from './types';
+import * as Styled from './styles';
+import { MenuContext } from './context';
+
+export const MenuPopover = forwardRef<
+ HTMLDivElement,
+ WordPressComponentProps< MenuPopoverProps, 'div', false >
+>( function MenuPopover(
+ { gutter, children, shift, modal = true, ...otherProps },
+ ref
+) {
+ const menuContext = useContext( MenuContext );
+
+ // Extract the side from the applied placement — useful for animations.
+ // Using `currentPlacement` instead of `placement` to make sure that we
+ // use the final computed placement (including "flips" etc).
+ const appliedPlacementSide = Ariakit.useStoreState(
+ menuContext?.store,
+ 'currentPlacement'
+ )?.split( '-' )[ 0 ];
+
+ const hideOnEscape = useCallback(
+ ( event: React.KeyboardEvent< Element > ) => {
+ // Pressing Escape can cause unexpected consequences (ie. exiting
+ // full screen mode on MacOs, close parent modals...).
+ event.preventDefault();
+ // Returning `true` causes the menu to hide.
+ return true;
+ },
+ []
+ );
+
+ const computedDirection = Ariakit.useStoreState( menuContext?.store, 'rtl' )
+ ? 'rtl'
+ : 'ltr';
+
+ const wrapperProps = useMemo(
+ () => ( {
+ dir: computedDirection,
+ style: {
+ direction:
+ computedDirection as React.CSSProperties[ 'direction' ],
+ },
+ } ),
+ [ computedDirection ]
+ );
+
+ if ( ! menuContext?.store ) {
+ throw new Error(
+ 'Menu.Popover can only be rendered inside a Menu component'
+ );
+ }
+
+ return (
+ (
+ // Two wrappers are needed for the entry animation, where the menu
+ // container scales with a different factor than its contents.
+ // The {...renderProps} are passed to the inner wrapper, so that the
+ // menu element is the direct parent of the menu item elements.
+
+
+
+ ) }
+ >
+ { children }
+
+ );
+} );
diff --git a/packages/components/src/menu/stories/index.story.tsx b/packages/components/src/menu/stories/index.story.tsx
index ad4794057e0e0..dcd890370a1e0 100644
--- a/packages/components/src/menu/stories/index.story.tsx
+++ b/packages/components/src/menu/stories/index.story.tsx
@@ -20,6 +20,7 @@ import Button from '../../button';
import Modal from '../../modal';
import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill';
import { ContextSystemProvider } from '../../context';
+import type { MenuProps } from '../types';
const meta: Meta< typeof Menu > = {
id: 'components-experimental-menu',
@@ -44,10 +45,15 @@ const meta: Meta< typeof Menu > = {
ItemLabel: Menu.ItemLabel,
// @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
ItemHelpText: Menu.ItemHelpText,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ TriggerButton: Menu.TriggerButton,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ SubmenuTriggerItem: Menu.SubmenuTriggerItem,
+ // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170
+ Popover: Menu.Popover,
},
argTypes: {
children: { control: false },
- trigger: { control: false },
},
tags: [ 'status-private' ],
parameters: {
@@ -61,95 +67,103 @@ const meta: Meta< typeof Menu > = {
};
export default meta;
-export const Default: StoryFn< typeof Menu > = ( props ) => (
+export const Default: StoryFn< typeof Menu > = ( props: MenuProps ) => (
-
- Label
-
-
- Label
- Help text
-
-
- Label
-
- The menu item help text is automatically truncated when there
- are more than two lines of text
-
-
-
- Label
-
- This item doesn't close the menu on click
-
-
- Disabled item
-
-
- Group label
- }>
- With prefix
+ }
+ >
+ Open menu
+
+
+
+ Label
- With suffix
- }
- suffix="⌥⌘T"
- >
- Disabled with prefix and suffix
- And help text
+
+ Label
+ Help text
-
+
+ Label
+
+ The menu item help text is automatically truncated when
+ there are more than two lines of text
+
+
+
+ Label
+
+ This item doesn't close the menu on click
+
+
+ Disabled item
+
+
+ Group label
+ }>
+ With prefix
+
+ With suffix
+ }
+ suffix="⌥⌘T"
+ >
+
+ Disabled with prefix and suffix
+
+ And help text
+
+
+
);
-Default.args = {
- trigger: (
-
- Open menu
-
- ),
-};
+Default.args = {};
-export const WithSubmenu: StoryFn< typeof Menu > = ( props ) => (
+export const WithSubmenu: StoryFn< typeof Menu > = ( props: MenuProps ) => (
- Level 1 item
-
+ }
+ >
+ Open menu
+
+
+ Level 1 item
+
+
Submenu trigger item with a long label
-
- }
- >
-
- Level 2 item
-
-
- Level 2 item
-
-
+
- Submenu trigger
+ Level 2 item
- }
- >
-
- Level 3 item
-
-
- Level 3 item
-
+
+ Level 2 item
+
+
+
+ Submenu trigger
+
+
+
+ Level 3 item
+
+
+ Level 3 item
+
+
+
+
-
+
);
WithSubmenu.args = {
...Default.args,
};
-export const WithCheckboxes: StoryFn< typeof Menu > = ( props ) => {
+export const WithCheckboxes: StoryFn< typeof Menu > = ( props: MenuProps ) => {
const [ isAChecked, setAChecked ] = useState( false );
const [ isBChecked, setBChecked ] = useState( true );
const [ multipleCheckboxesValue, setMultipleCheckboxesValue ] = useState<
@@ -169,94 +183,113 @@ export const WithCheckboxes: StoryFn< typeof Menu > = ( props ) => {
return (
-
-
- Single selection, uncontrolled
-
-
- Checkbox item A
- Initially unchecked
-
-
- Checkbox item B
- Initially checked
-
-
-
-
- Single selection, controlled
- setAChecked( e.target.checked ) }
- >
- Checkbox item A
- Initially unchecked
-
- setBChecked( e.target.checked ) }
- >
- Checkbox item B
- Initially checked
-
-
-
-
-
- Multiple selection, uncontrolled
-
-
- Checkbox item A
- Initially unchecked
-
-
- Checkbox item B
- Initially checked
-
-
-
-
-
- Multiple selection, controlled
-
-
- Checkbox item A
- Initially unchecked
-
-
- Checkbox item B
- Initially checked
-
-
+ }
+ >
+ Open menu
+
+
+
+
+ Single selection, uncontrolled
+
+
+ Checkbox item A
+
+ Initially unchecked
+
+
+
+ Checkbox item B
+ Initially checked
+
+
+
+
+
+ Single selection, controlled
+
+ {
+ setAChecked( e.target.checked );
+ } }
+ >
+ Checkbox item A
+
+ Initially unchecked
+
+
+ setBChecked( e.target.checked ) }
+ >
+ Checkbox item B
+ Initially checked
+
+
+
+
+
+ Multiple selection, uncontrolled
+
+
+ Checkbox item A
+
+ Initially unchecked
+
+
+
+ Checkbox item B
+ Initially checked
+
+
+
+
+
+ Multiple selection, controlled
+
+
+ Checkbox item A
+
+ Initially unchecked
+
+
+
+ Checkbox item B
+ Initially checked
+
+
+
);
};
@@ -264,7 +297,7 @@ WithCheckboxes.args = {
...Default.args,
};
-export const WithRadios: StoryFn< typeof Menu > = ( props ) => {
+export const WithRadios: StoryFn< typeof Menu > = ( props: MenuProps ) => {
const [ radioValue, setRadioValue ] = useState( 'two' );
const onRadioChange: React.ComponentProps<
typeof Menu.RadioItem
@@ -272,43 +305,54 @@ export const WithRadios: StoryFn< typeof Menu > = ( props ) => {
return (
-
- Uncontrolled
-
- Radio item 1
- Initially unchecked
-
-
- Radio item 2
- Initially checked
-
-
-
-
- Controlled
-
- Radio item 1
- Initially unchecked
-
-
- Radio item 2
- Initially checked
-
-
+ }
+ >
+ Open menu
+
+
+
+ Uncontrolled
+
+ Radio item 1
+
+ Initially unchecked
+
+
+
+ Radio item 2
+ Initially checked
+
+
+
+
+ Controlled
+
+ Radio item 1
+
+ Initially unchecked
+
+
+
+ Radio item 2
+ Initially checked
+
+
+
);
};
@@ -323,7 +367,7 @@ const modalOnTopOfMenuPopover = css`
`;
// For more examples with `Modal`, check https://ariakit.org/examples/menu-wordpress-modal
-export const WithModals: StoryFn< typeof Menu > = ( props ) => {
+export const WithModals: StoryFn< typeof Menu > = ( props: MenuProps ) => {
const [ isOuterModalOpen, setOuterModalOpen ] = useState( false );
const [ isInnerModalOpen, setInnerModalOpen ] = useState( false );
@@ -333,29 +377,40 @@ export const WithModals: StoryFn< typeof Menu > = ( props ) => {
return (
<>
- setOuterModalOpen( true ) }
- hideOnClick={ false }
- >
- Open outer modal
-
- setInnerModalOpen( true ) }
- hideOnClick={ false }
+
+ }
>
- Open inner modal
-
- { isInnerModalOpen && (
- setInnerModalOpen( false ) }
- overlayClassName={ modalOverlayClassName }
+ Open menu
+
+
+ setOuterModalOpen( true ) }
+ hideOnClick={ false }
>
- Modal's contents
- setInnerModalOpen( false ) }>
- Close
-
-
- ) }
+ Open outer modal
+
+ setInnerModalOpen( true ) }
+ hideOnClick={ false }
+ >
+ Open inner modal
+
+ { isInnerModalOpen && (
+ setInnerModalOpen( false ) }
+ overlayClassName={ modalOverlayClassName }
+ >
+ Modal's contents
+ setInnerModalOpen( false ) }
+ >
+ Close
+
+
+ ) }
+
{ isOuterModalOpen && (
{
);
};
-export const WithSlotFill: StoryFn< typeof Menu > = ( props ) => {
+export const WithSlotFill: StoryFn< typeof Menu > = ( props: MenuProps ) => {
return (
-
- Item
-
-
+
+ }
+ >
+ Open menu
+
+
+
+ Item
+
+
+
Item from fill
-
+
+ Submenu from fill
+
+
- Submenu from fill
+
+ Submenu item from fill
+
- }
- >
-
- Submenu item from fill
-
+
@@ -461,28 +526,34 @@ const toolbarVariantContextValue = {
variant: 'toolbar',
},
};
-export const ToolbarVariant: StoryFn< typeof Menu > = ( props ) => (
+export const ToolbarVariant: StoryFn< typeof Menu > = ( props: MenuProps ) => (
// TODO: add toolbar
-
- Level 1 item
-
-
- Level 1 item
-
-
-
- Submenu trigger
-
- }
+ }
>
+ Open menu
+
+
- Level 2 item
+ Level 1 item
-
+
+ Level 1 item
+
+
+
+
+ Submenu trigger
+
+
+
+ Level 2 item
+
+
+
+
);
@@ -490,7 +561,7 @@ ToolbarVariant.args = {
...Default.args,
};
-export const InsideModal: StoryFn< typeof Menu > = ( props ) => {
+export const InsideModal: StoryFn< typeof Menu > = ( props: MenuProps ) => {
const [ isModalOpen, setModalOpen ] = useState( false );
return (
<>
@@ -502,28 +573,44 @@ export const InsideModal: StoryFn< typeof Menu > = ( props ) => {
Open modal
{ isModalOpen && (
- setModalOpen( false ) }>
+ setModalOpen( false ) }
+ title="Menu inside modal"
+ >
-
- Level 1 item
-
-
- Level 1 item
-
-
-
-
- Submenu trigger
-
-
+
}
>
+ Open menu
+
+
+
+ Level 1 item
+
- Level 2 item
+ Level 1 item
-
+
+
+
+
+ Submenu trigger
+
+
+
+
+
+ Level 2 item
+
+
+
+
+
setModalOpen( false ) }>
Close modal
diff --git a/packages/components/src/menu/submenu-trigger-item.tsx b/packages/components/src/menu/submenu-trigger-item.tsx
new file mode 100644
index 0000000000000..23932a14bdaff
--- /dev/null
+++ b/packages/components/src/menu/submenu-trigger-item.tsx
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+
+/**
+ * WordPress dependencies
+ */
+import { forwardRef, useContext } from '@wordpress/element';
+import { chevronRightSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { WordPressComponentProps } from '../context';
+import type { MenuItemProps } from './types';
+import { MenuContext } from './context';
+import { MenuItem } from './item';
+import * as Styled from './styles';
+
+export const MenuSubmenuTriggerItem = forwardRef<
+ HTMLDivElement,
+ WordPressComponentProps< MenuItemProps, 'div', false >
+>( function MenuSubmenuTriggerItem( { suffix, ...otherProps }, ref ) {
+ const menuContext = useContext( MenuContext );
+
+ if ( ! menuContext?.store.parent ) {
+ throw new Error(
+ 'Menu.SubmenuTriggerItem can only be rendered inside a nested Menu component'
+ );
+ }
+
+ return (
+
+ { suffix }
+
+ >
+ }
+ />
+ }
+ />
+ );
+} );
diff --git a/packages/components/src/menu/test/index.tsx b/packages/components/src/menu/test/index.tsx
index 60276cdb2379a..42e1516d94bbb 100644
--- a/packages/components/src/menu/test/index.tsx
+++ b/packages/components/src/menu/test/index.tsx
@@ -18,17 +18,28 @@ const delay = ( delayInMs: number ) => {
return new Promise( ( resolve ) => setTimeout( resolve, delayInMs ) );
};
+// Open dropdown => open menu
+// Submenu trigger item => open submenu
+
describe( 'Menu', () => {
// See https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
it( 'should follow the WAI-ARIA spec', async () => {
render(
- Open dropdown }>
- Menu item
-
- Submenu trigger item }>
- Submenu item 1
- Submenu item 2
-
+
+ Open dropdown
+
+ Menu item
+
+
+
+ Submenu trigger item
+
+
+ Submenu item 1
+ Submenu item 2
+
+
+
);
@@ -84,8 +95,11 @@ describe( 'Menu', () => {
describe( 'pointer and keyboard interactions', () => {
it( 'should open and focus the menu when clicking the trigger', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -105,10 +119,13 @@ describe( 'Menu', () => {
it( 'should open and focus the first item when pressing the arrow down key on the trigger', async () => {
render(
- Open dropdown }>
- First item
- Second item
- Third item
+
+ Open dropdown
+
+ First item
+ Second item
+ Third item
+
);
@@ -135,10 +152,13 @@ describe( 'Menu', () => {
it( 'should open and focus the first item when pressing the space key on the trigger', async () => {
render(
- Open dropdown }>
- First item
- Second item
- Third item
+
+ Open dropdown
+
+ First item
+ Second item
+ Third item
+
);
@@ -165,8 +185,11 @@ describe( 'Menu', () => {
it( 'should close when pressing the escape key', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -194,8 +217,11 @@ describe( 'Menu', () => {
it( 'should close when clicking outside of the content', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -209,8 +235,11 @@ describe( 'Menu', () => {
it( 'should close when clicking on a menu item', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -224,8 +253,11 @@ describe( 'Menu', () => {
it( 'should not close when clicking on a menu item when the `hideOnClick` prop is set to `false`', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -239,8 +271,11 @@ describe( 'Menu', () => {
it( 'should not close when clicking on a disabled menu item', async () => {
render(
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
);
@@ -254,16 +289,22 @@ describe( 'Menu', () => {
it( 'should reveal submenu content when hovering over the submenu trigger', async () => {
render(
- Open dropdown }>
- Menu item 1
- Menu item 2
- Submenu trigger item }
- >
- Submenu item 1
- Submenu item 2
-
- Menu item 3
+
+ Open dropdown
+
+ Menu item 1
+ Menu item 2
+
+
+ Submenu trigger item
+
+
+ Submenu item 1
+ Submenu item 2
+
+
+ Menu item 3
+
);
@@ -288,16 +329,22 @@ describe( 'Menu', () => {
it( 'should navigate menu items and subitems using the arrow, spacebar and enter keys', async () => {
render(
- Open dropdown }>
- Menu item 1
- Menu item 2
- Submenu trigger item }
- >
- Submenu item 1
- Submenu item 2
-
- Menu item 3
+
+ Open dropdown
+
+ Menu item 1
+ Menu item 2
+
+
+ Submenu trigger item
+
+
+ Submenu item 1
+ Submenu item 2
+
+
+ Menu item 3
+
);
@@ -407,25 +454,28 @@ describe( 'Menu', () => {
setRadioValue( e.target.value );
};
return (
- Open dropdown }>
-
-
- Radio item one
-
-
- Radio item two
-
-
+
+ Open dropdown
+
+
+
+ Radio item one
+
+
+ Radio item two
+
+
+
);
};
@@ -484,28 +534,31 @@ describe( 'Menu', () => {
it( 'should check radio items and keep the menu open when clicking (uncontrolled)', async () => {
const onRadioValueChangeSpy = jest.fn();
render(
- Open dropdown }>
-
-
- onRadioValueChangeSpy( e.target.value )
- }
- >
- Radio item one
-
-
- onRadioValueChangeSpy( e.target.value )
- }
- >
- Radio item two
-
-
+
+ Open dropdown
+
+
+
+ onRadioValueChangeSpy( e.target.value )
+ }
+ >
+ Radio item one
+
+
+ onRadioValueChangeSpy( e.target.value )
+ }
+ >
+ Radio item two
+
+
+
);
@@ -568,38 +621,41 @@ describe( 'Menu', () => {
useState< boolean >();
return (
- Open dropdown }>
- {
- onCheckboxValueChangeSpy(
- e.target.name,
- e.target.value,
- e.target.checked
- );
- setItemOneChecked( e.target.checked );
- } }
- >
- Checkbox item one
-
-
- {
- onCheckboxValueChangeSpy(
- e.target.name,
- e.target.value,
- e.target.checked
- );
- setItemTwoChecked( e.target.checked );
- } }
- >
- Checkbox item two
-
+
+ Open dropdown
+
+ {
+ onCheckboxValueChangeSpy(
+ e.target.name,
+ e.target.value,
+ e.target.checked
+ );
+ setItemOneChecked( e.target.checked );
+ } }
+ >
+ Checkbox item one
+
+
+ {
+ onCheckboxValueChangeSpy(
+ e.target.name,
+ e.target.value,
+ e.target.checked
+ );
+ setItemTwoChecked( e.target.checked );
+ } }
+ >
+ Checkbox item two
+
+
);
};
@@ -691,35 +747,38 @@ describe( 'Menu', () => {
const onCheckboxValueChangeSpy = jest.fn();
render(
- Open dropdown }>
- {
- onCheckboxValueChangeSpy(
- e.target.name,
- e.target.value,
- e.target.checked
- );
- } }
- >
- Checkbox item one
-
-
- {
- onCheckboxValueChangeSpy(
- e.target.name,
- e.target.value,
- e.target.checked
- );
- } }
- >
- Checkbox item two
-
+
+ Open dropdown
+
+ {
+ onCheckboxValueChangeSpy(
+ e.target.name,
+ e.target.value,
+ e.target.checked
+ );
+ } }
+ >
+ Checkbox item one
+
+
+ {
+ onCheckboxValueChangeSpy(
+ e.target.name,
+ e.target.value,
+ e.target.checked
+ );
+ } }
+ >
+ Checkbox item two
+
+
);
@@ -809,8 +868,11 @@ describe( 'Menu', () => {
it( 'should be modal by default', async () => {
render(
<>
- Open dropdown }>
- Menu item
+
+ Open dropdown
+
+ Menu item
+
Button outside of dropdown
>
@@ -836,11 +898,11 @@ describe( 'Menu', () => {
it( 'should not be modal when the `modal` prop is set to `false`', async () => {
render(
<>
- Open dropdown }
- modal={ false }
- >
- Menu item
+
+ Open dropdown
+
+ Menu item
+
Button outside of dropdown
>
@@ -873,8 +935,13 @@ describe( 'Menu', () => {
describe( 'items prefix and suffix', () => {
it( 'should display a prefix on regular items', async () => {
render(
- Open dropdown }>
- Item prefix> }>Menu item
+
+ Open dropdown
+
+ Item prefix> }>
+ Menu item
+
+
);
@@ -895,8 +962,13 @@ describe( 'Menu', () => {
it( 'should display a suffix on regular items', async () => {
render(
- Open dropdown }>
- Item suffix> }>Menu item
+
+ Open dropdown
+
+ Item suffix> }>
+ Menu item
+
+
);
@@ -917,14 +989,17 @@ describe( 'Menu', () => {
it( 'should display a suffix on radio items', async () => {
render(
- Open dropdown }>
-
- Radio item one
-
+
+ Open dropdown
+
+
+ Radio item one
+
+
);
@@ -945,14 +1020,17 @@ describe( 'Menu', () => {
it( 'should display a suffix on checkbox items', async () => {
render(
- Open dropdown }>
-
- Checkbox item one
-
+
+ Open dropdown
+
+
+ Checkbox item one
+
+
);
@@ -975,9 +1053,12 @@ describe( 'Menu', () => {
describe( 'typeahead', () => {
it( 'should highlight matching item', async () => {
render(
- Open dropdown }>
- One
- Two
+
+ Open dropdown
+
+ One
+ Two
+
);
@@ -1008,9 +1089,12 @@ describe( 'Menu', () => {
it( 'should keep previous focus when no matches are found', async () => {
render(
- Open dropdown }>
- One
- Two
+
+ Open dropdown
+
+ One
+ Two
+
);
diff --git a/packages/components/src/menu/trigger-button.tsx b/packages/components/src/menu/trigger-button.tsx
new file mode 100644
index 0000000000000..b99804efef0f1
--- /dev/null
+++ b/packages/components/src/menu/trigger-button.tsx
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+
+/**
+ * WordPress dependencies
+ */
+import { forwardRef, useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { WordPressComponentProps } from '../context';
+import type { MenuTriggerButtonProps } from './types';
+import { MenuContext } from './context';
+
+export const MenuTriggerButton = forwardRef<
+ HTMLDivElement,
+ WordPressComponentProps< MenuTriggerButtonProps, 'button', false >
+>( function MenuTriggerButton( { children, disabled = false, ...props }, ref ) {
+ const menuContext = useContext( MenuContext );
+
+ if ( ! menuContext?.store ) {
+ throw new Error(
+ 'Menu.TriggerButton can only be rendered inside a Menu component'
+ );
+ }
+
+ if ( menuContext.store.parent ) {
+ throw new Error(
+ 'Menu.TriggerButton should not be rendered inside a nested Menu component. Use Menu.SubmenuTriggerItem instead.'
+ );
+ }
+
+ return (
+
+ { children }
+
+ );
+} );
diff --git a/packages/components/src/menu/types.ts b/packages/components/src/menu/types.ts
index 7b58cef241743..f58b5bcc89b95 100644
--- a/packages/components/src/menu/types.ts
+++ b/packages/components/src/menu/types.ts
@@ -16,10 +16,6 @@ export interface MenuContext {
}
export interface MenuProps {
- /**
- * The button triggering the menu popover.
- */
- trigger: React.ReactElement;
/**
* The contents of the menu (ie. one or more menu items).
*/
@@ -40,6 +36,19 @@ export interface MenuProps {
* Event handler called when the open state of the menu popover changes.
*/
onOpenChange?: ( open: boolean ) => void;
+ /**
+ * The placement of the menu popover.
+ *
+ * @default 'bottom-start' for root-level menus, 'right-start' for nested menus
+ */
+ placement?: Placement;
+}
+
+export interface MenuPopoverProps {
+ /**
+ * The contents of the dropdown.
+ */
+ children?: React.ReactNode;
/**
* The modality of the menu popover. When set to true, interaction with
* outside elements will be disabled and only menu content will be visible to
@@ -48,12 +57,6 @@ export interface MenuProps {
* @default true
*/
modal?: boolean;
- /**
- * The placement of the menu popover.
- *
- * @default 'bottom-start' for root-level menus, 'right-start' for nested menus
- */
- placement?: Placement;
/**
* The distance between the popover and the anchor element.
*
@@ -80,6 +83,50 @@ export interface MenuProps {
) => boolean );
}
+export interface MenuTriggerButtonProps {
+ /**
+ * The contents of the menu trigger button.
+ */
+ children?: React.ReactNode;
+ /**
+ * 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.MenuButtonProps[ 'render' ];
+ /**
+ * 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.MenuButtonProps[ '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.MenuButtonProps[ 'accessibleWhenDisabled' ];
+}
+
export interface MenuGroupProps {
/**
* The contents of the menu group (ie. an optional menu group label and one
@@ -118,6 +165,18 @@ export interface MenuItemProps {
* Determines if the element is disabled.
*/
disabled?: boolean;
+ /**
+ * 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.MenuItemProps[ 'render' ];
+ /**
+ * The ariakit store. This prop is only meant for internal use.
+ * @ignore
+ */
+ store?: Ariakit.MenuItemProps[ 'store' ];
}
export interface MenuCheckboxItemProps
diff --git a/packages/dataviews/src/components/dataviews-filters/add-filter.tsx b/packages/dataviews/src/components/dataviews-filters/add-filter.tsx
index 94aebb71ea587..3921fd88eaaa2 100644
--- a/packages/dataviews/src/components/dataviews-filters/add-filter.tsx
+++ b/packages/dataviews/src/components/dataviews-filters/add-filter.tsx
@@ -33,37 +33,40 @@ export function AddFilterMenu( {
view,
onChangeView,
setOpenedFilter,
- trigger,
+ triggerProps,
}: AddFilterProps & {
- trigger: React.ReactNode;
+ triggerProps: React.ComponentProps< typeof Menu.TriggerButton >;
} ) {
const inactiveFilters = filters.filter( ( filter ) => ! filter.isVisible );
return (
-
- { inactiveFilters.map( ( filter ) => {
- return (
- {
- setOpenedFilter( filter.field );
- onChangeView( {
- ...view,
- page: 1,
- filters: [
- ...( view.filters || [] ),
- {
- field: filter.field,
- value: undefined,
- operator: filter.operators[ 0 ],
- },
- ],
- } );
- } }
- >
- { filter.name }
-
- );
- } ) }
+
+
+
+ { inactiveFilters.map( ( filter ) => {
+ return (
+ {
+ setOpenedFilter( filter.field );
+ onChangeView( {
+ ...view,
+ page: 1,
+ filters: [
+ ...( view.filters || [] ),
+ {
+ field: filter.field,
+ value: undefined,
+ operator: filter.operators[ 0 ],
+ },
+ ],
+ } );
+ } }
+ >
+ { filter.name }
+
+ );
+ } ) }
+
);
}
@@ -78,18 +81,19 @@ function AddFilter(
const inactiveFilters = filters.filter( ( filter ) => ! filter.isVisible );
return (
- { __( 'Add filter' ) }
-
- }
+ triggerProps={ {
+ render: (
+
+ ),
+ children: __( 'Add filter' ),
+ } }
{ ...{ filters, view, onChangeView, setOpenedFilter } }
/>
);
diff --git a/packages/dataviews/src/components/dataviews-filters/index.tsx b/packages/dataviews/src/components/dataviews-filters/index.tsx
index 440df4f17310d..180e17d4b7f0c 100644
--- a/packages/dataviews/src/components/dataviews-filters/index.tsx
+++ b/packages/dataviews/src/components/dataviews-filters/index.tsx
@@ -136,7 +136,7 @@ export function FiltersToggle( {
view={ view }
onChangeView={ onChangeViewWithFilterVisibility }
setOpenedFilter={ setOpenedFilter }
- trigger={ buttonComponent }
+ triggerProps={ { render: buttonComponent } }
/>
) : (
( {
);
return (
<>
-
- }
- placement="bottom-end"
- >
-
+
+ }
/>
+
+
+
{ !! activeModalAction && (
view.type === v.type );
return (
-
- }
- >
- { availableLayouts.map( ( layout ) => {
- const config = VIEW_LAYOUTS.find( ( v ) => v.type === layout );
- if ( ! config ) {
- return null;
+
+
}
- return (
- ) => {
- switch ( e.target.value ) {
- case 'list':
- case 'grid':
- case 'table':
- const viewWithoutLayout = { ...view };
- if ( 'layout' in viewWithoutLayout ) {
- delete viewWithoutLayout.layout;
- }
- // @ts-expect-error
- return onChangeView( {
- ...viewWithoutLayout,
- type: e.target.value,
- ...defaultLayouts[ e.target.value ],
- } );
- }
- warning( 'Invalid dataview' );
- } }
- >
- { config.label }
-
- );
- } ) }
+ />
+
+ { availableLayouts.map( ( layout ) => {
+ const config = VIEW_LAYOUTS.find(
+ ( v ) => v.type === layout
+ );
+ if ( ! config ) {
+ return null;
+ }
+ return (
+
+ ) => {
+ switch ( e.target.value ) {
+ case 'list':
+ case 'grid':
+ case 'table':
+ const viewWithoutLayout = { ...view };
+ if ( 'layout' in viewWithoutLayout ) {
+ delete viewWithoutLayout.layout;
+ }
+ // @ts-expect-error
+ return onChangeView( {
+ ...viewWithoutLayout,
+ type: e.target.value,
+ ...defaultLayouts[ e.target.value ],
+ } );
+ }
+ warning( 'Invalid dataview' );
+ } }
+ >
+ { config.label }
+
+ );
+ } ) }
+
);
}
diff --git a/packages/dataviews/src/dataviews-layouts/list/index.tsx b/packages/dataviews/src/dataviews-layouts/list/index.tsx
index 960865164fd6c..651834599f8ac 100644
--- a/packages/dataviews/src/dataviews-layouts/list/index.tsx
+++ b/packages/dataviews/src/dataviews-layouts/list/index.tsx
@@ -215,32 +215,36 @@ function ListItem< Item >( {
) }
{ ! hasOnlyOnePrimaryAction && (
-
- }
- />
- }
- placement="bottom-end"
- >
-
+
+ }
+ />
+ }
/>
+
+
+
{ !! activeModalAction && (
(
! field.filterBy?.isPrimary;
return (
-
- { header }
- { view.sort && isSorted && (
-
- { sortArrows[ view.sort.direction ] }
-
- ) }
-
- }
- style={ { minWidth: '240px' } }
- >
-
- { isSortable && (
-
- { SORTING_DIRECTIONS.map(
- ( direction: SortDirection ) => {
- const isChecked =
- view.sort &&
- isSorted &&
- view.sort.direction === direction;
+
+
+ }
+ >
+ { header }
+ { view.sort && isSorted && (
+
+ { sortArrows[ view.sort.direction ] }
+
+ ) }
+
+
+
+ { isSortable && (
+
+ { SORTING_DIRECTIONS.map(
+ ( direction: SortDirection ) => {
+ const isChecked =
+ view.sort &&
+ isSorted &&
+ view.sort.direction === direction;
- const value = `${ fieldId }-${ direction }`;
+ const value = `${ fieldId }-${ direction }`;
- return (
- {
- onChangeView( {
- ...view,
- sort: {
- field: fieldId,
- direction,
- },
- } );
- } }
- >
-
- { sortLabels[ direction ] }
-
-
- );
- }
- ) }
-
- ) }
- { canAddFilter && (
-
- }
- onClick={ () => {
- setOpenedFilter( fieldId );
- onChangeView( {
- ...view,
- page: 1,
- filters: [
- ...( view.filters || [] ),
- {
- field: fieldId,
- value: undefined,
- operator: operators[ 0 ],
- },
- ],
- } );
- } }
- >
-
- { __( 'Add filter' ) }
-
-
-
- ) }
- { ( canMove || isHidable ) && field && (
-
- { canMove && (
+ return (
+ {
+ onChangeView( {
+ ...view,
+ sort: {
+ field: fieldId,
+ direction,
+ },
+ } );
+ } }
+ >
+
+ { sortLabels[ direction ] }
+
+
+ );
+ }
+ ) }
+
+ ) }
+ { canAddFilter && (
+
}
- disabled={ index < 1 }
+ prefix={ }
onClick={ () => {
+ setOpenedFilter( fieldId );
onChangeView( {
...view,
- fields: [
- ...( visibleFieldIds.slice(
- 0,
- index - 1
- ) ?? [] ),
- fieldId,
- visibleFieldIds[ index - 1 ],
- ...visibleFieldIds.slice(
- index + 1
- ),
+ page: 1,
+ filters: [
+ ...( view.filters || [] ),
+ {
+ field: fieldId,
+ value: undefined,
+ operator: operators[ 0 ],
+ },
],
} );
} }
>
- { __( 'Move left' ) }
+ { __( 'Add filter' ) }
- ) }
- { canMove && (
- }
- disabled={ index >= visibleFieldIds.length - 1 }
- onClick={ () => {
- onChangeView( {
- ...view,
- fields: [
- ...( visibleFieldIds.slice(
- 0,
- index
- ) ?? [] ),
- visibleFieldIds[ index + 1 ],
- fieldId,
- ...visibleFieldIds.slice(
- index + 2
+
+ ) }
+ { ( canMove || isHidable ) && field && (
+
+ { canMove && (
+ }
+ disabled={ index < 1 }
+ onClick={ () => {
+ onChangeView( {
+ ...view,
+ fields: [
+ ...( visibleFieldIds.slice(
+ 0,
+ index - 1
+ ) ?? [] ),
+ fieldId,
+ visibleFieldIds[ index - 1 ],
+ ...visibleFieldIds.slice(
+ index + 1
+ ),
+ ],
+ } );
+ } }
+ >
+
+ { __( 'Move left' ) }
+
+
+ ) }
+ { canMove && (
+ }
+ disabled={
+ index >= visibleFieldIds.length - 1
+ }
+ onClick={ () => {
+ onChangeView( {
+ ...view,
+ fields: [
+ ...( visibleFieldIds.slice(
+ 0,
+ index
+ ) ?? [] ),
+ visibleFieldIds[ index + 1 ],
+ fieldId,
+ ...visibleFieldIds.slice(
+ index + 2
+ ),
+ ],
+ } );
+ } }
+ >
+
+ { __( 'Move right' ) }
+
+
+ ) }
+ { isHidable && field && (
+ }
+ onClick={ () => {
+ onHide( field );
+ onChangeView( {
+ ...view,
+ fields: visibleFieldIds.filter(
+ ( id ) => id !== fieldId
),
- ],
- } );
- } }
- >
-
- { __( 'Move right' ) }
-
-
- ) }
- { isHidable && field && (
- }
- onClick={ () => {
- onHide( field );
- onChangeView( {
- ...view,
- fields: visibleFieldIds.filter(
- ( id ) => id !== fieldId
- ),
- } );
- } }
- >
-
- { __( 'Hide column' ) }
-
-
- ) }
-
- ) }
-
+ } );
+ } }
+ >
+
+ { __( 'Hide column' ) }
+
+
+ ) }
+
+ ) }
+
+
);
} );
diff --git a/packages/edit-site/src/components/global-styles/font-sizes/font-size.js b/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
index 25dcc69185cae..cca4a26e1b736 100644
--- a/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
+++ b/packages/edit-site/src/components/global-styles/font-sizes/font-size.js
@@ -166,25 +166,34 @@ function FontSize() {
marginBottom={ 0 }
paddingX={ 4 }
>
-
- }
- >
-
-
- { __( 'Rename' ) }
-
-
-
-
- { __( 'Delete' ) }
-
-
+
+
+ }
+ />
+
+
+
+ { __( 'Rename' ) }
+
+
+
+
+ { __( 'Delete' ) }
+
+
+
diff --git a/packages/edit-site/src/components/global-styles/font-sizes/font-sizes.js b/packages/edit-site/src/components/global-styles/font-sizes/font-sizes.js
index 7498dd7c78fb3..5b759d1e0468d 100644
--- a/packages/edit-site/src/components/global-styles/font-sizes/font-sizes.js
+++ b/packages/edit-site/src/components/global-styles/font-sizes/font-sizes.js
@@ -26,14 +26,15 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';
-const { Menu } = unlock( componentsPrivateApis );
-const { useGlobalSetting } = unlock( blockEditorPrivateApis );
import Subtitle from '../subtitle';
import { NavigationButtonAsItem } from '../navigation-button';
import { getNewIndexFromPresets } from '../utils';
import ScreenHeader from '../header';
import ConfirmResetFontSizesDialog from './confirm-reset-font-sizes-dialog';
+const { Menu } = unlock( componentsPrivateApis );
+const { useGlobalSetting } = unlock( blockEditorPrivateApis );
+
function FontSizeGroup( {
label,
origin,
@@ -80,24 +81,31 @@ function FontSizeGroup( {
/>
) }
{ !! handleResetFontSizes && (
-
- }
- >
-
-
- { origin === 'custom'
- ? __( 'Remove font size presets' )
- : __( 'Reset font size presets' ) }
-
-
+
+
+ }
+ />
+
+
+
+ { origin === 'custom'
+ ? __(
+ 'Remove font size presets'
+ )
+ : __(
+ 'Reset font size presets'
+ ) }
+
+
+
) }
diff --git a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
index 0de1f2c99362c..9fd7959a6c09c 100644
--- a/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
+++ b/packages/edit-site/src/components/global-styles/shadows-edit-panel.js
@@ -163,33 +163,38 @@ export default function ShadowsEditPanel() {
-
- }
- >
- { ( category === 'custom'
- ? customShadowMenuItems
- : presetShadowMenuItems
- ).map( ( item ) => (
- onMenuClick( item.action ) }
- disabled={
- item.action === 'reset' &&
- selectedShadow.shadow ===
- baseSelectedShadow.shadow
- }
- >
-
- { item.label }
-
-
- ) ) }
+
+
+ }
+ />
+
+ { ( category === 'custom'
+ ? customShadowMenuItems
+ : presetShadowMenuItems
+ ).map( ( item ) => (
+
+ onMenuClick( item.action )
+ }
+ disabled={
+ item.action === 'reset' &&
+ selectedShadow.shadow ===
+ baseSelectedShadow.shadow
+ }
+ >
+
+ { item.label }
+
+
+ ) ) }
+
diff --git a/packages/editor/src/components/post-actions/index.js b/packages/editor/src/components/post-actions/index.js
index 4b541d52d429c..d6adf6c072166 100644
--- a/packages/editor/src/components/post-actions/index.js
+++ b/packages/editor/src/components/post-actions/index.js
@@ -74,24 +74,26 @@ export default function PostActions( { postType, postId, onActionPerformed } ) {
return (
<>
-
- }
- placement="bottom-end"
- >
-
+
+ }
/>
+
+
+
{ !! activeModalAction && (
Date: Mon, 16 Dec 2024 16:06:40 +0530
Subject: [PATCH 43/66] More Block: Refactor settings panel to use ToolsPanel
(#67905)
Co-authored-by: im3dabasia
Co-authored-by: Mamaduka
Co-authored-by: Mayank-Tripathi32
Co-authored-by: fabiankaegy
---
packages/block-library/src/more/edit.js | 44 ++++++++++++++++++-------
1 file changed, 32 insertions(+), 12 deletions(-)
diff --git a/packages/block-library/src/more/edit.js b/packages/block-library/src/more/edit.js
index bcad7ec1b8366..21e26b47bfb16 100644
--- a/packages/block-library/src/more/edit.js
+++ b/packages/block-library/src/more/edit.js
@@ -2,7 +2,11 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { PanelBody, ToggleControl } from '@wordpress/components';
+import {
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+ ToggleControl,
+} from '@wordpress/components';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { ENTER } from '@wordpress/keycodes';
import { getDefaultBlockName, createBlock } from '@wordpress/blocks';
@@ -40,17 +44,33 @@ export default function MoreEdit( {
return (
<>
-
-
-
+ {
+ setAttributes( {
+ noTeaser: false,
+ } );
+ } }
+ >
+ noTeaser }
+ onDeselect={ () =>
+ setAttributes( { noTeaser: false } )
+ }
+ >
+
+
+
Date: Mon, 16 Dec 2024 16:45:05 +0530
Subject: [PATCH 44/66] Query Pagination: Refactor settings panel to use
ToolsPanel (#67914)
Co-authored-by: himanshupathak95
Co-authored-by: akasunil
Co-authored-by: Mamaduka
Co-authored-by: fabiankaegy
---
.../src/query-pagination/edit.js | 58 ++++++++++++++-----
1 file changed, 45 insertions(+), 13 deletions(-)
diff --git a/packages/block-library/src/query-pagination/edit.js b/packages/block-library/src/query-pagination/edit.js
index e051c2e67e7e5..469ff14d0e967 100644
--- a/packages/block-library/src/query-pagination/edit.js
+++ b/packages/block-library/src/query-pagination/edit.js
@@ -9,7 +9,10 @@ import {
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
-import { PanelBody } from '@wordpress/components';
+import {
+ __experimentalToolsPanel as ToolsPanel,
+ __experimentalToolsPanelItem as ToolsPanelItem,
+} from '@wordpress/components';
import { useEffect } from '@wordpress/element';
/**
@@ -17,6 +20,7 @@ import { useEffect } from '@wordpress/element';
*/
import { QueryPaginationArrowControls } from './query-pagination-arrow-controls';
import { QueryPaginationLabelControl } from './query-pagination-label-control';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
const TEMPLATE = [
[ 'core/query-pagination-previous' ],
@@ -56,26 +60,54 @@ export default function QueryPaginationEdit( {
setAttributes( { showLabel: true } );
}
}, [ paginationArrow, setAttributes, showLabel ] );
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
return (
<>
{ hasNextPreviousBlocks && (
-
- {
- setAttributes( { paginationArrow: value } );
- } }
- />
- { paginationArrow !== 'none' && (
- {
+ setAttributes( {
+ paginationArrow: 'none',
+ showLabel: true,
+ } );
+ } }
+ dropdownMenuProps={ dropdownMenuProps }
+ >
+ paginationArrow !== 'none' }
+ label={ __( 'Pagination arrow' ) }
+ onDeselect={ () =>
+ setAttributes( { paginationArrow: 'none' } )
+ }
+ isShownByDefault
+ >
+ {
- setAttributes( { showLabel: value } );
+ setAttributes( { paginationArrow: value } );
} }
/>
+
+ { paginationArrow !== 'none' && (
+ ! showLabel }
+ label={ __( 'Show text' ) }
+ onDeselect={ () =>
+ setAttributes( { showLabel: true } )
+ }
+ isShownByDefault
+ >
+ {
+ setAttributes( { showLabel: value } );
+ } }
+ />
+
) }
-
+
) }
From 2ee383c032df762b77b5bd6c480582e0772a0db0 Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Mon, 16 Dec 2024 16:28:00 +0400
Subject: [PATCH 45/66] Query Pagination: Fix 'undo' trap (#68022)
Co-authored-by: Mamaduka
Co-authored-by: fabiankaegy
---
.../block-library/src/query-pagination/edit.js | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/packages/block-library/src/query-pagination/edit.js b/packages/block-library/src/query-pagination/edit.js
index 469ff14d0e967..8ca0705058be2 100644
--- a/packages/block-library/src/query-pagination/edit.js
+++ b/packages/block-library/src/query-pagination/edit.js
@@ -8,7 +8,7 @@ import {
useInnerBlocksProps,
store as blockEditorStore,
} from '@wordpress/block-editor';
-import { useSelect } from '@wordpress/data';
+import { useDispatch, useSelect } from '@wordpress/data';
import {
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
@@ -50,17 +50,27 @@ export default function QueryPaginationEdit( {
},
[ clientId ]
);
+ const { __unstableMarkNextChangeAsNotPersistent } =
+ useDispatch( blockEditorStore );
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
const blockProps = useBlockProps();
const innerBlocksProps = useInnerBlocksProps( blockProps, {
template: TEMPLATE,
} );
+
// Always show label text if paginationArrow is set to 'none'.
useEffect( () => {
if ( paginationArrow === 'none' && ! showLabel ) {
+ __unstableMarkNextChangeAsNotPersistent();
setAttributes( { showLabel: true } );
}
- }, [ paginationArrow, setAttributes, showLabel ] );
- const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+ }, [
+ paginationArrow,
+ setAttributes,
+ showLabel,
+ __unstableMarkNextChangeAsNotPersistent,
+ ] );
+
return (
<>
{ hasNextPreviousBlocks && (
From 52b0db4e9232555cef4bbf19d81c75ef37e87ccb Mon Sep 17 00:00:00 2001
From: Dion Hulse
Date: Tue, 17 Dec 2024 01:39:13 +1000
Subject: [PATCH 46/66] Combine the release steps to ensure that releases are
tagged (#65591)
Currently the release process is timing out. This is due to the 5 minute timeout included in the trunk commit command.
The current steps appear to be attempting to set the `Stable Tag` to the current stable (not the current release) but is setting it to the upcoming release anyway.
The process can be simplified by combining the Stable Tag / Trunk Commit / SVN copy into a single commit.
This will avoid situations where the trunk commit passes but the tag is not created.
Raising the timeout to 10 minutes (which is the default in SVN) should hopefully resolve the underlying timeout.
Co-authored-by: dd32
Co-authored-by: andrewserong
Co-authored-by: mikachan
Co-authored-by: ockham
Co-authored-by: talldan
---
.../upload-release-to-plugin-repo.yml | 27 +++++++------------
1 file changed, 9 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/upload-release-to-plugin-repo.yml b/.github/workflows/upload-release-to-plugin-repo.yml
index e866964e69b2d..4d2b0a66a7e7d 100644
--- a/.github/workflows/upload-release-to-plugin-repo.yml
+++ b/.github/workflows/upload-release-to-plugin-repo.yml
@@ -168,7 +168,9 @@ jobs:
steps:
- name: Check out Gutenberg trunk from WP.org plugin repo
- run: svn checkout "$PLUGIN_REPO_URL/trunk" --username "$SVN_USERNAME" --password "$SVN_PASSWORD"
+ run: |
+ svn checkout "$PLUGIN_REPO_URL/trunk" --username "$SVN_USERNAME" --password "$SVN_PASSWORD"
+ svn checkout "$PLUGIN_REPO_URL/tags" --depth=immediates --username "$SVN_USERNAME" --password "$SVN_PASSWORD"
- name: Delete everything
working-directory: ./trunk
@@ -182,7 +184,7 @@ jobs:
unzip gutenberg.zip -d trunk
rm gutenberg.zip
- - name: Replace the stable tag placeholder with the existing stable tag on the SVN repository
+ - name: Replace the stable tag placeholder with the new version
env:
STABLE_TAG_PLACEHOLDER: 'Stable tag: V\.V\.V'
run: |
@@ -194,27 +196,16 @@ jobs:
name: changelog trunk
path: trunk
- - name: Commit the content changes
+ - name: Commit the release
working-directory: ./trunk
run: |
svn st | grep '^?' | awk '{print $2}' | xargs -r svn add
svn st | grep '^!' | awk '{print $2}' | xargs -r svn rm
- svn commit -m "Committing version $VERSION" \
+ svn cp . "../tags/$VERSION"
+ svn commit . "../tags/$VERSION" \
+ -m "Releasing version $VERSION" \
--no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" \
- --config-option=servers:global:http-timeout=300
-
- - name: Create the SVN tag
- working-directory: ./trunk
- run: |
- svn copy "$PLUGIN_REPO_URL/trunk" "$PLUGIN_REPO_URL/tags/$VERSION" -m "Tagging version $VERSION" \
- --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD"
-
- - name: Update the plugin's stable version
- working-directory: ./trunk
- run: |
- sed -i "s/Stable tag: ${STABLE_VERSION_REGEX}/Stable tag: ${VERSION}/g" ./readme.txt
- svn commit -m "Releasing version $VERSION" \
- --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD"
+ --config-option=servers:global:http-timeout=600
upload-tag:
name: Publish as tag
From f5b8bec543d3172c73bc59db2bf4dc497dc5fede Mon Sep 17 00:00:00 2001
From: Jon Surrell
Date: Mon, 16 Dec 2024 17:07:18 +0100
Subject: [PATCH 47/66] Interactivity API: Prevent each directive errors and
allow more iterables (#67798)
Fix an issue where the each directive would error when a non-iterable value is received.
Support more iterable values in the each directive.
---
Co-authored-by: sirreal
Co-authored-by: michalczaplinski
Co-authored-by: luisherranz
---
.../directive-each/render.php | 51 +++++++++++++++++++
.../interactive-blocks/directive-each/view.js | 22 ++++++++
packages/interactivity/CHANGELOG.md | 8 +++
packages/interactivity/src/directives.tsx | 27 +++++++---
packages/interactivity/src/hooks.tsx | 2 +-
.../interactivity/directive-each.spec.ts | 35 ++++++++++++-
6 files changed, 135 insertions(+), 10 deletions(-)
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php
index 47eb351d837e7..bfac62feb1359 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/render.php
@@ -260,3 +260,54 @@
data-wp-text="context.callbackRunCount"
>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
index 6ceef82864d9d..7577810b6bb87 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
@@ -13,6 +13,28 @@ const { state } = store( 'directive-each' );
store( 'directive-each', {
state: {
letters: [ 'A', 'B', 'C' ],
+ eachUndefined: undefined,
+ eachNull: null,
+ eachArray: [ 'an', 'array' ],
+ eachSet: new Set( [ 'a', 'set' ] ),
+ eachString: 'str',
+ *eachGenerator() {
+ yield 'a';
+ yield 'generator';
+ },
+ eachIterator: {
+ [ Symbol.iterator ]() {
+ const vals = [ 'implements', 'iterator' ];
+ let i = 0;
+ return {
+ next() {
+ return i < vals.length
+ ? { value: vals[ i++ ], done: false }
+ : { done: true };
+ },
+ };
+ },
+ },
},
} );
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 6b32e4ec35a97..ec54cdfd7c036 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+### Enhancements
+
+- Allow more iterables to be used in each directives ([#67798](https://github.com/WordPress/gutenberg/pull/67798)).
+
+### Bug Fixes
+
+- Fix an error when the value used in an each directive is not iterable ([#67798](https://github.com/WordPress/gutenberg/pull/67798)).
+
## 6.14.0 (2024-12-11)
## 6.13.0 (2024-11-27)
diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx
index 31e07d095e0a4..bddd017b1c99d 100644
--- a/packages/interactivity/src/directives.tsx
+++ b/packages/interactivity/src/directives.tsx
@@ -4,7 +4,7 @@
/**
* External dependencies
*/
-import { h as createElement, type RefObject } from 'preact';
+import { h as createElement, type VNode, type RefObject } from 'preact';
import { useContext, useMemo, useRef } from 'preact/hooks';
/**
@@ -567,11 +567,19 @@ export default () => {
const [ entry ] = each;
const { namespace } = entry;
- const list = evaluate( entry );
+ const iterable = evaluate( entry );
+
+ if ( typeof iterable?.[ Symbol.iterator ] !== 'function' ) {
+ return;
+ }
+
const itemProp = isNonDefaultDirectiveSuffix( entry )
? kebabToCamelCase( entry.suffix )
: 'item';
- return list.map( ( item ) => {
+
+ const result: VNode< any >[] = [];
+
+ for ( const item of iterable ) {
const itemContext = proxifyContext(
proxifyState( namespace, {} ),
inheritedValue.client[ namespace ]
@@ -596,12 +604,15 @@ export default () => {
? getEvaluate( { scope } )( eachKey[ 0 ] )
: item;
- return createElement(
- Provider,
- { value: mergedContext, key },
- element.props.content
+ result.push(
+ createElement(
+ Provider,
+ { value: mergedContext, key },
+ element.props.content
+ )
);
- } );
+ }
+ return result;
},
{ priority: 20 }
);
diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx
index e9b9f48ba3518..7899e3eafd228 100644
--- a/packages/interactivity/src/hooks.tsx
+++ b/packages/interactivity/src/hooks.tsx
@@ -77,7 +77,7 @@ interface DirectiveArgs {
}
export interface DirectiveCallback {
- ( args: DirectiveArgs ): VNode< any > | null | void;
+ ( args: DirectiveArgs ): VNode< any > | VNode< any >[] | null | void;
}
interface DirectiveOptions {
diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts
index 511b38e7ddbb8..3c015e63fe4bc 100644
--- a/test/e2e/specs/interactivity/directive-each.spec.ts
+++ b/test/e2e/specs/interactivity/directive-each.spec.ts
@@ -18,7 +18,7 @@ test.describe( 'data-wp-each', () => {
await utils.deleteAllPosts();
} );
- test( 'should use `item` as the defaul item name in the context', async ( {
+ test( 'should use `item` as the default item name in the context', async ( {
page,
} ) => {
const elements = page.getByTestId( 'letters' ).getByTestId( 'item' );
@@ -500,4 +500,37 @@ test.describe( 'data-wp-each', () => {
await expect( element ).toHaveText( 'beta' );
await expect( callbackRunCount ).toHaveText( '1' );
} );
+
+ for ( const testId of [
+ 'each-with-unset',
+ 'each-with-null',
+ 'each-with-undefined',
+ ] ) {
+ test( `does not error with non-iterable values: ${ testId }`, async ( {
+ page,
+ } ) => {
+ await expect( page.getByTestId( testId ) ).toBeEmpty();
+ } );
+ }
+
+ for ( const [ testId, values ] of [
+ [ 'each-with-array', [ 'an', 'array' ] ],
+ [ 'each-with-set', [ 'a', 'set' ] ],
+ [ 'each-with-string', [ 's', 't', 'r' ] ],
+ [ 'each-with-generator', [ 'a', 'generator' ] ],
+
+ // TODO: Is there a problem with proxies here?
+ // [ 'each-with-iterator', [ 'implements', 'iterator' ] ],
+ ] as const ) {
+ test( `support different each iterable values: ${ testId }`, async ( {
+ page,
+ } ) => {
+ const element = page.getByTestId( testId );
+ for ( const value of values ) {
+ await expect(
+ element.getByText( value, { exact: true } )
+ ).toBeVisible();
+ }
+ } );
+ }
} );
From 2eb82b40582b91537476db60f5dcf9f65015101f Mon Sep 17 00:00:00 2001
From: Nik Tsekouras
Date: Mon, 16 Dec 2024 18:14:00 +0200
Subject: [PATCH 48/66] DataViews: Hide actions related UI in `grid` when no
actions or bulk actions are passed (#68033)
Co-authored-by: ntsekouras
Co-authored-by: oandregal
---
.../src/dataviews-layouts/grid/index.tsx | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/packages/dataviews/src/dataviews-layouts/grid/index.tsx b/packages/dataviews/src/dataviews-layouts/grid/index.tsx
index b1f074c568299..cdb70219d229a 100644
--- a/packages/dataviews/src/dataviews-layouts/grid/index.tsx
+++ b/packages/dataviews/src/dataviews-layouts/grid/index.tsx
@@ -22,7 +22,10 @@ import { useInstanceId } from '@wordpress/compose';
*/
import ItemActions from '../../components/dataviews-item-actions';
import DataViewsSelectionCheckbox from '../../components/dataviews-selection-checkbox';
-import { useHasAPossibleBulkAction } from '../../components/dataviews-bulk-actions';
+import {
+ useHasAPossibleBulkAction,
+ useSomeItemHasAPossibleBulkAction,
+} from '../../components/dataviews-bulk-actions';
import type {
Action,
NormalizedField,
@@ -47,6 +50,7 @@ interface GridItemProps< Item > {
descriptionField?: NormalizedField< Item >;
regularFields: NormalizedField< Item >[];
badgeFields: NormalizedField< Item >[];
+ hasBulkActions: boolean;
}
function GridItem< Item >( {
@@ -63,6 +67,7 @@ function GridItem< Item >( {
descriptionField,
regularFields,
badgeFields,
+ hasBulkActions,
}: GridItemProps< Item > ) {
const { showTitle = true, showMedia = true, showDescription = true } = view;
const hasBulkAction = useHasAPossibleBulkAction( actions, item );
@@ -135,7 +140,7 @@ function GridItem< Item >( {
{ renderedMediaField }
) }
- { showMedia && renderedMediaField && (
+ { hasBulkActions && showMedia && renderedMediaField && (
( {
{ renderedTitleField }
-
+ { !! actions?.length && (
+
+ ) }
{ showDescription && descriptionField?.render && (
@@ -258,6 +265,7 @@ export default function ViewGrid< Item >( {
);
const hasData = !! data?.length;
const updatedPreviewSize = useUpdatedPreviewSizeOnViewportChange();
+ const hasBulkActions = useSomeItemHasAPossibleBulkAction( actions, data );
const usedPreviewSize = updatedPreviewSize || view.layout?.previewSize;
const gridStyle = usedPreviewSize
? {
@@ -292,6 +300,7 @@ export default function ViewGrid< Item >( {
descriptionField={ descriptionField }
regularFields={ regularFields }
badgeFields={ badgeFields }
+ hasBulkActions={ hasBulkActions }
/>
);
} ) }
From bce0a50d6f019123a9a7f4a4b071f4390e0f4dfe Mon Sep 17 00:00:00 2001
From: Marco Ciampini
Date: Mon, 16 Dec 2024 17:55:18 +0100
Subject: [PATCH 49/66] CreateTemplatePartModalContents: use native radio
inputs (#67702)
* Refactor to vanilla radio inputs, rewrite styles
* Remove unnecessary classname
* Apply instance ID to all ids and radio names
* Use darker shade of gray for description
---
Co-authored-by: ciampo
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: jameskoster
Co-authored-by: jsnajdr
Co-authored-by: tyxla
---
.../create-template-part-modal/index.tsx | 106 ++++++++------
.../create-template-part-modal/style.scss | 133 +++++++++++-------
2 files changed, 140 insertions(+), 99 deletions(-)
diff --git a/packages/fields/src/components/create-template-part-modal/index.tsx b/packages/fields/src/components/create-template-part-modal/index.tsx
index 4043a7824fac4..8728f2681a493 100644
--- a/packages/fields/src/components/create-template-part-modal/index.tsx
+++ b/packages/fields/src/components/create-template-part-modal/index.tsx
@@ -5,13 +5,8 @@ import {
Icon,
BaseControl,
TextControl,
- Flex,
- FlexItem,
- FlexBlock,
Button,
Modal,
- __experimentalRadioGroup as RadioGroup,
- __experimentalRadio as Radio,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
} from '@wordpress/components';
@@ -40,6 +35,13 @@ import {
useExistingTemplateParts,
} from './utils';
+function getAreaRadioId( value: string, instanceId: number ) {
+ return `fields-create-template-part-modal__area-option-${ value }-${ instanceId }`;
+}
+function getAreaRadioDescriptionId( value: string, instanceId: number ) {
+ return `fields-create-template-part-modal__area-option-description-${ value }-${ instanceId }`;
+}
+
type CreateTemplatePartModalContentsProps = {
defaultArea?: string;
blocks: any[];
@@ -201,52 +203,66 @@ export function CreateTemplatePartModalContents( {
onChange={ setTitle }
required
/>
-
-
- value && typeof value === 'string'
- ? setArea( value )
- : () => void 0
- }
- checked={ area }
- >
+
+
+ { __( 'Area' ) }
+
+
{ ( defaultTemplatePartAreas ?? [] ).map( ( item ) => {
const icon = getTemplatePartIcon( item.icon );
return (
-
-
-
-
-
-
- { item.label }
- { item.description }
-
-
-
- { area === item.area && (
-
- ) }
-
-
-
+
{
+ setArea( item.area );
+ } }
+ aria-describedby={ getAreaRadioDescriptionId(
+ item.area,
+ instanceId
+ ) }
+ />
+
+
+ { item.label }
+
+
+
+ { item.description }
+
+
);
} ) }
-
-
+
+
*:not(.fields-create-template-part-modal__area-radio-label) {
+ pointer-events: none;
+ }
+}
+
+.fields-create-template-part-modal__area-radio-label {
+ // Capture pointer clicks for the whole radio wrapper
+ &::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ }
+
+ input[type="radio"]:not(:checked) ~ &::before {
+ cursor: pointer;
+ }
+
+ input[type="radio"]:focus-visible ~ &::before {
+ outline: 4px solid transparent;
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+ }
+}
+
+.fields-create-template-part-modal__area-radio-icon,
+.fields-create-template-part-modal__area-radio-checkmark {
+ fill: currentColor;
+}
+
+.fields-create-template-part-modal__area-radio-checkmark {
+ input[type="radio"]:not(:checked) ~ & {
+ opacity: 0;
+ }
+}
+
+.fields-create-template-part-modal__area-radio-description {
+ grid-column: 2 / 3;
+ margin: 0;
+
+ color: $gray-700;
+ font-size: $helptext-font-size;
+ line-height: normal;
+ text-wrap: pretty;
- .components-button.fields-create-template-part-modal__area-radio {
- display: block;
- width: 100%;
- height: 100%;
- text-align: left;
- padding: $grid-unit-15;
-
- &,
- &.is-secondary:hover,
- &.is-primary:hover {
- margin: 0;
- background-color: inherit;
- border-bottom: $border-width solid $gray-700;
- border-radius: 0;
-
- &:not(:focus) {
- box-shadow: none;
- }
-
- &:focus {
- border-bottom: $border-width solid $white;
- }
-
- &:last-of-type {
- border-bottom: none;
- }
- }
-
- &:not(:hover),
- &[aria-checked="true"] {
- color: $gray-900;
- cursor: auto;
-
- .fields-create-template-part-modal__option-label div {
- color: $gray-600;
- }
- }
-
- .fields-create-template-part-modal__option-label {
- padding-top: $grid-unit-05;
- white-space: normal;
-
- div {
- padding-top: $grid-unit-05;
- font-size: $helptext-font-size;
- }
- }
-
- .fields-create-template-part-modal__checkbox {
- margin-left: auto;
- min-width: $grid-unit-30;
- }
+ input[type="radio"]:not(:checked):hover ~ & {
+ color: inherit;
}
}
From 37da337e433c36f50d3e5f446e1abdabb5e046ab Mon Sep 17 00:00:00 2001
From: Dave Smith
Date: Mon, 16 Dec 2024 17:08:31 +0000
Subject: [PATCH 50/66] Allow replace operation on empty default block in Zoom
Out (#68026)
* Allow replace operation on empty default block in Zoom Out
* Remove operation type from conditional check
Co-authored-by: getdave
Co-authored-by: draganescu
---
.../src/components/use-block-drop-zone/index.js | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js
index 221e5ab74ebb2..529eb199fb76a 100644
--- a/packages/block-editor/src/components/use-block-drop-zone/index.js
+++ b/packages/block-editor/src/components/use-block-drop-zone/index.js
@@ -456,7 +456,14 @@ export default function useBlockDropZone( {
const [ targetIndex, operation, nearestSide ] =
dropTargetPosition;
- if ( isZoomOut() && operation !== 'insert' ) {
+ const isTargetIndexEmptyDefaultBlock =
+ blocksData[ targetIndex ]?.isUnmodifiedDefaultBlock;
+
+ if (
+ isZoomOut() &&
+ ! isTargetIndexEmptyDefaultBlock &&
+ operation !== 'insert'
+ ) {
return;
}
From cddf656e8d9e1c0d162362a717cffb7945190437 Mon Sep 17 00:00:00 2001
From: Sarthak Nagoshe <83178197+sarthaknagoshe2002@users.noreply.github.com>
Date: Mon, 16 Dec 2024 22:51:57 +0530
Subject: [PATCH 51/66] Update nested-blocks-inner-blocks.md (#66911)
Addressed the issue https://github.com/WordPress/gutenberg/issues/64801
---
docs/how-to-guides/block-tutorial/nested-blocks-inner-blocks.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/how-to-guides/block-tutorial/nested-blocks-inner-blocks.md b/docs/how-to-guides/block-tutorial/nested-blocks-inner-blocks.md
index b90b466853079..3c75e1e82668f 100644
--- a/docs/how-to-guides/block-tutorial/nested-blocks-inner-blocks.md
+++ b/docs/how-to-guides/block-tutorial/nested-blocks-inner-blocks.md
@@ -70,7 +70,7 @@ By default this behavior is disabled until the `directInsert` prop is set to `tr
## Template
-Use the template property to define a set of blocks that prefill the InnerBlocks component when inserted. You can set attributes on the blocks to define their use. The example below shows a book review template using InnerBlocks component and setting placeholders values to show the block usage.
+Use the template property to define a set of blocks that prefill the InnerBlocks component when it has no existing content.. You can set attributes on the blocks to define their use. The example below shows a book review template using InnerBlocks component and setting placeholders values to show the block usage.
```js
From 00e5bd1560e1b6e9464076e453b6d80f4e451ca1 Mon Sep 17 00:00:00 2001
From: Juan Aldasoro
Date: Mon, 16 Dec 2024 19:11:53 +0100
Subject: [PATCH 52/66] Fix reference to `wp-env start` (#68034)
Co-authored-by: juanfra
Co-authored-by: fabiankaegy
---
docs/getting-started/devenv/get-started-with-wp-env.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/getting-started/devenv/get-started-with-wp-env.md b/docs/getting-started/devenv/get-started-with-wp-env.md
index 74942ea3ee93b..a6427deb863b7 100644
--- a/docs/getting-started/devenv/get-started-with-wp-env.md
+++ b/docs/getting-started/devenv/get-started-with-wp-env.md
@@ -47,7 +47,7 @@ wp-env start
Once the script completes, you can access the local environment at: http://localhost:8888
. Log into the WordPress dashboard using username `admin` and password `password`.
- Some projects, like Gutenberg, include their own specific wp-env
configurations, and the documentation might prompt you to run npm run start wp-env
instead.
+ Some projects, like Gutenberg, include their own specific wp-env
configurations, and the documentation might prompt you to run npm run wp-env start
instead.
For more information on controlling the Docker environment, see the [@wordpress/env package](/packages/env/README.md) readme.
From 581de9f893ff63c156b118f7244211f417fe7543 Mon Sep 17 00:00:00 2001
From: Rohit Mathur <62138457+rohitmathur-7@users.noreply.github.com>
Date: Tue, 17 Dec 2024 08:20:02 +0530
Subject: [PATCH 53/66] Remove patterns from the Quick Inserter to prevent
misuse in block-specific contexts (#67738)
* Remove patterns from quick inserter
* Remove commented code
* Removed prioritizePatterns instances where possible
----
Co-authored-by: rohitmathur-7
Co-authored-by: talldan
Co-authored-by: richtabor
---
.../src/components/inserter/index.js | 13 ---------
.../src/components/inserter/quick-inserter.js | 27 ++-----------------
2 files changed, 2 insertions(+), 38 deletions(-)
diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js
index 1af81d0231a1a..47304a7d77174 100644
--- a/packages/block-editor/src/components/inserter/index.js
+++ b/packages/block-editor/src/components/inserter/index.js
@@ -29,7 +29,6 @@ const defaultRenderToggle = ( {
blockTitle,
hasSingleBlockType,
toggleProps = {},
- prioritizePatterns,
} ) => {
const {
as: Wrapper = Button,
@@ -45,8 +44,6 @@ const defaultRenderToggle = ( {
_x( 'Add %s', 'directly add the only allowed block' ),
blockTitle
);
- } else if ( ! label && prioritizePatterns ) {
- label = __( 'Add pattern' );
} else if ( ! label ) {
label = _x( 'Add block', 'Generic label for block inserter button' );
}
@@ -113,7 +110,6 @@ class Inserter extends Component {
toggleProps,
hasItems,
renderToggle = defaultRenderToggle,
- prioritizePatterns,
} = this.props;
return renderToggle( {
@@ -124,7 +120,6 @@ class Inserter extends Component {
hasSingleBlockType,
directInsertBlock,
toggleProps,
- prioritizePatterns,
} );
}
@@ -147,7 +142,6 @@ class Inserter extends Component {
// This prop is experimental to give some time for the quick inserter to mature
// Feel free to make them stable after a few releases.
__experimentalIsQuick: isQuick,
- prioritizePatterns,
onSelectOrClose,
selectBlockOnInsert,
} = this.props;
@@ -171,7 +165,6 @@ class Inserter extends Component {
rootClientId={ rootClientId }
clientId={ clientId }
isAppender={ isAppender }
- prioritizePatterns={ prioritizePatterns }
selectBlockOnInsert={ selectBlockOnInsert }
/>
);
@@ -230,7 +223,6 @@ export default compose( [
hasInserterItems,
getAllowedBlocks,
getDirectInsertBlock,
- getSettings,
} = select( blockEditorStore );
const { getBlockVariations } = select( blocksStore );
@@ -243,8 +235,6 @@ export default compose( [
const directInsertBlock =
shouldDirectInsert && getDirectInsertBlock( rootClientId );
- const settings = getSettings();
-
const hasSingleBlockType =
allowedBlocks?.length === 1 &&
getBlockVariations( allowedBlocks[ 0 ].name, 'inserter' )
@@ -262,9 +252,6 @@ export default compose( [
allowedBlockType,
directInsertBlock,
rootClientId,
- prioritizePatterns:
- settings.__experimentalPreferPatternsOnRoot &&
- ! rootClientId,
};
}
),
diff --git a/packages/block-editor/src/components/inserter/quick-inserter.js b/packages/block-editor/src/components/inserter/quick-inserter.js
index 9f393a7ce1520..498030a0019dc 100644
--- a/packages/block-editor/src/components/inserter/quick-inserter.js
+++ b/packages/block-editor/src/components/inserter/quick-inserter.js
@@ -16,21 +16,17 @@ import { useSelect } from '@wordpress/data';
*/
import InserterSearchResults from './search-results';
import useInsertionPoint from './hooks/use-insertion-point';
-import usePatternsState from './hooks/use-patterns-state';
import useBlockTypesState from './hooks/use-block-types-state';
import { store as blockEditorStore } from '../../store';
const SEARCH_THRESHOLD = 6;
const SHOWN_BLOCK_TYPES = 6;
-const SHOWN_BLOCK_PATTERNS = 2;
-const SHOWN_BLOCK_PATTERNS_WITH_PRIORITIZATION = 4;
export default function QuickInserter( {
onSelect,
rootClientId,
clientId,
isAppender,
- prioritizePatterns,
selectBlockOnInsert,
hasSearch = true,
} ) {
@@ -47,12 +43,6 @@ export default function QuickInserter( {
onInsertBlocks,
true
);
- const [ patterns ] = usePatternsState(
- onInsertBlocks,
- destinationRootClientId,
- undefined,
- true
- );
const { setInserterIsOpened, insertionIndex } = useSelect(
( select ) => {
@@ -70,12 +60,7 @@ export default function QuickInserter( {
[ clientId ]
);
- const showPatterns =
- patterns.length && ( !! filterValue || prioritizePatterns );
- const showSearch =
- hasSearch &&
- ( ( showPatterns && patterns.length > SEARCH_THRESHOLD ) ||
- blockTypes.length > SEARCH_THRESHOLD );
+ const showSearch = hasSearch && blockTypes.length > SEARCH_THRESHOLD;
useEffect( () => {
if ( setInserterIsOpened ) {
@@ -94,13 +79,6 @@ export default function QuickInserter( {
} );
};
- let maxBlockPatterns = 0;
- if ( showPatterns ) {
- maxBlockPatterns = prioritizePatterns
- ? SHOWN_BLOCK_PATTERNS_WITH_PRIORITIZATION
- : SHOWN_BLOCK_PATTERNS;
- }
-
return (
From 9cba28abb77d71fd9a472b9847df4edcd755a5bf Mon Sep 17 00:00:00 2001
From: Daniel Richards
Date: Tue, 17 Dec 2024 14:36:49 +0800
Subject: [PATCH 54/66] Fix indentation in upload-media package.json (#68037)
---
packages/upload-media/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json
index ec7eaabbb3940..14ae4f77dc5cb 100644
--- a/packages/upload-media/package.json
+++ b/packages/upload-media/package.json
@@ -27,7 +27,7 @@
"wpScript": true,
"types": "build-types",
"dependencies": {
- "@shopify/web-worker": "^6.4.0",
+ "@shopify/web-worker": "^6.4.0",
"@wordpress/api-fetch": "file:../api-fetch",
"@wordpress/blob": "file:../blob",
"@wordpress/compose": "file:../compose",
From 6294216f9306addd2097372b0e467589df583acf Mon Sep 17 00:00:00 2001
From: George Mamadashvili
Date: Tue, 17 Dec 2024 12:46:29 +0400
Subject: [PATCH 55/66] Block Editor: Fix Iframe error for links without 'href'
(#68024)
Co-authored-by: Mamaduka
Co-authored-by: talldan
---
packages/block-editor/src/components/iframe/index.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js
index 751e940dd166c..8ec4b24106ebf 100644
--- a/packages/block-editor/src/components/iframe/index.js
+++ b/packages/block-editor/src/components/iframe/index.js
@@ -192,7 +192,7 @@ function Iframe( {
// Appending a hash to the current URL will not reload the
// page. This is useful for e.g. footnotes.
const href = event.target.getAttribute( 'href' );
- if ( href.startsWith( '#' ) ) {
+ if ( href?.startsWith( '#' ) ) {
iFrameDocument.defaultView.location.hash =
href.slice( 1 );
}
From 0fdd0a6782f12d835d976bd3f3bd493d54fa5f2a Mon Sep 17 00:00:00 2001
From: Eshaan Dabasiya <76681468+im3dabasia@users.noreply.github.com>
Date: Tue, 17 Dec 2024 16:28:10 +0530
Subject: [PATCH 56/66] Ensure all instances of the ToolsPanel uses the
useToolsPanelDropdownMenuProps hook for placement (#68019)
Co-authored-by: im3dabasia
Co-authored-by: fabiankaegy
---
packages/block-library/src/button/edit.js | 4 ++++
packages/block-library/src/column/edit.js | 7 +++++++
packages/block-library/src/columns/edit.js | 4 ++++
packages/block-library/src/details/edit.js | 7 +++++++
packages/block-library/src/post-excerpt/edit.js | 8 +++++++-
packages/block-library/src/social-link/edit.js | 3 +++
packages/block-library/src/social-links/edit.js | 8 ++++++++
packages/block-library/src/table/edit.js | 4 ++++
packages/block-library/src/tag-cloud/edit.js | 7 +++++++
9 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js
index 520da26ef9671..d00e522f5a5d2 100644
--- a/packages/block-library/src/button/edit.js
+++ b/packages/block-library/src/button/edit.js
@@ -9,6 +9,7 @@ import clsx from 'clsx';
import { NEW_TAB_TARGET, NOFOLLOW_REL } from './constants';
import { getUpdatedLinkAttributes } from './get-updated-link-attributes';
import removeAnchorTag from '../utils/remove-anchor-tag';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
/**
* WordPress dependencies
@@ -115,10 +116,13 @@ function useEnter( props ) {
}
function WidthPanel( { selectedWidth, setAttributes } ) {
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+
return (
setAttributes( { width: undefined } ) }
+ dropdownMenuProps={ dropdownMenuProps }
>
{
setAttributes( { width: undefined } );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
width !== undefined }
diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js
index d79dfe4fc94a4..cad79c356fe03 100644
--- a/packages/block-library/src/columns/edit.js
+++ b/packages/block-library/src/columns/edit.js
@@ -40,6 +40,7 @@ import {
getRedistributedColumnWidths,
toWidthPrecision,
} from './utils';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
const DEFAULT_BLOCK = {
name: 'core/column',
@@ -145,6 +146,8 @@ function ColumnInspectorControls( {
replaceInnerBlocks( clientId, innerBlocks );
}
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
+
return (
{ canInsertColumnBlock && (
showMoreOnNewLine !== true }
diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js
index 43fb305d52ffa..4cd24505fd552 100644
--- a/packages/block-library/src/social-link/edit.js
+++ b/packages/block-library/src/social-link/edit.js
@@ -36,6 +36,7 @@ import { keyboardReturn } from '@wordpress/icons';
* Internal dependencies
*/
import { getIconBySite, getNameBySite } from './social-list';
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
const SocialLinkURLPopover = ( {
url,
@@ -109,6 +110,7 @@ const SocialLinkEdit = ( {
clientId,
} ) => {
const { url, service, label = '', rel } = attributes;
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
const {
showLabels,
iconColor,
@@ -200,6 +202,7 @@ const SocialLinkEdit = ( {
resetAll={ () => {
setAttributes( { label: undefined } );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
hasFixedLayout !== true }
diff --git a/packages/block-library/src/tag-cloud/edit.js b/packages/block-library/src/tag-cloud/edit.js
index b41e47faec369..7e544d2474f04 100644
--- a/packages/block-library/src/tag-cloud/edit.js
+++ b/packages/block-library/src/tag-cloud/edit.js
@@ -24,6 +24,11 @@ import {
import ServerSideRender from '@wordpress/server-side-render';
import { store as coreStore } from '@wordpress/core-data';
+/**
+ * Internal dependencies
+ */
+import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
+
/**
* Minimum number of tags a user can show using this block.
*
@@ -51,6 +56,7 @@ function TagCloudEdit( { attributes, setAttributes } ) {
} = attributes;
const [ availableUnits ] = useSettings( 'spacing.units' );
+ const dropdownMenuProps = useToolsPanelDropdownMenuProps();
// The `pt` unit is used as the default value and is therefore
// always considered an available unit.
@@ -129,6 +135,7 @@ function TagCloudEdit( { attributes, setAttributes } ) {
largestFontSize: '22pt',
} );
} }
+ dropdownMenuProps={ dropdownMenuProps }
>
taxonomy !== 'post_tag' }
From ca298eda93393b613987104cd556dfd7ed12a1be Mon Sep 17 00:00:00 2001
From: Dave Smith
Date: Tue, 17 Dec 2024 12:33:47 +0000
Subject: [PATCH 57/66] Use custom name in block sidebar if available
(retaining block type information) (#65641)
* Use Badge for the block type
* Wrap when custom name is long enough
* Fix calc
Co-authored-by: getdave
Co-authored-by: draganescu
Co-authored-by: mikachan
Co-authored-by: jameskoster
Co-authored-by: t-hamano
Co-authored-by: jeryj
Co-authored-by: richtabor
---
.../src/components/block-card/index.js | 26 +++++++++++++++----
.../src/components/block-card/style.scss | 5 ++++
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/packages/block-editor/src/components/block-card/index.js b/packages/block-editor/src/components/block-card/index.js
index c8a12a3be5ef6..cdf52ee7df031 100644
--- a/packages/block-editor/src/components/block-card/index.js
+++ b/packages/block-editor/src/components/block-card/index.js
@@ -11,16 +11,21 @@ import {
Button,
__experimentalText as Text,
__experimentalVStack as VStack,
+ privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { chevronLeft, chevronRight } from '@wordpress/icons';
import { __, _x, isRTL, sprintf } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
+import { createInterpolateElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import BlockIcon from '../block-icon';
import { store as blockEditorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
+
+const { Badge } = unlock( componentsPrivateApis );
function BlockCard( { title, icon, description, blockType, className, name } ) {
if ( blockType ) {
@@ -67,11 +72,22 @@ function BlockCard( { title, icon, description, blockType, className, name } ) {
{ name?.length
- ? sprintf(
- // translators: 1: Custom block name. 2: Block title.
- _x( '%1$s (%2$s)', 'block label' ),
- name,
- title
+ ? createInterpolateElement(
+ sprintf(
+ // translators: 1: Custom block name. 2: Block title.
+ _x(
+ '%1$s %2$s ',
+ 'block label'
+ ),
+ name,
+ title
+ ),
+ {
+ span: (
+
+ ),
+ badge: ,
+ }
)
: title }
diff --git a/packages/block-editor/src/components/block-card/style.scss b/packages/block-editor/src/components/block-card/style.scss
index 42cf77aa4b0a8..b02310fb630f4 100644
--- a/packages/block-editor/src/components/block-card/style.scss
+++ b/packages/block-editor/src/components/block-card/style.scss
@@ -7,6 +7,10 @@
.block-editor-block-card__title {
font-weight: 500;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: calc($grid-unit-10 / 2) $grid-unit-10;
&.block-editor-block-card__title {
font-size: $default-font-size;
@@ -27,3 +31,4 @@
.block-editor-block-card.is-synced .block-editor-block-icon {
color: var(--wp-block-synced-color);
}
+
From 7dd3127f0715effbf71951837cfb85c3a558054a Mon Sep 17 00:00:00 2001
From: Dave Smith
Date: Tue, 17 Dec 2024 12:48:58 +0000
Subject: [PATCH 58/66] Fix don't show inserter in Zoom Out dropzone when the
text is visible (#68031)
* Hide inserter
* Comment case
Co-authored-by: Sarah Norris <1645628+mikachan@users.noreply.github.com>
---------
Co-authored-by: getdave
Co-authored-by: MaggieCabrera
Co-authored-by: mikachan
---
.../block-tools/zoom-out-mode-inserters.js | 24 ++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
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 17af902bf9baf..96f905293849b 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
@@ -20,6 +20,8 @@ function ZoomOutModeInserters() {
setInserterIsOpened,
sectionRootClientId,
selectedBlockClientId,
+ blockInsertionPoint,
+ insertionPointVisible,
} = useSelect( ( select ) => {
const {
getSettings,
@@ -27,6 +29,8 @@ function ZoomOutModeInserters() {
getSelectionStart,
getSelectedBlockClientId,
getSectionRootClientId,
+ getBlockInsertionPoint,
+ isBlockInsertionPointVisible,
} = unlock( select( blockEditorStore ) );
const root = getSectionRootClientId();
@@ -38,6 +42,8 @@ function ZoomOutModeInserters() {
setInserterIsOpened:
getSettings().__experimentalSetIsInserterOpened,
selectedBlockClientId: getSelectedBlockClientId(),
+ blockInsertionPoint: getBlockInsertionPoint(),
+ insertionPointVisible: isBlockInsertionPointVisible(),
};
}, [] );
@@ -62,7 +68,19 @@ function ZoomOutModeInserters() {
const index = blockOrder.findIndex(
( clientId ) => selectedBlockClientId === clientId
);
- const nextClientId = blockOrder[ index + 1 ];
+
+ const insertionIndex = index + 1;
+
+ const nextClientId = blockOrder[ insertionIndex ];
+
+ // If the block insertion point is visible, and the insertion
+ // indicies match then we don't need to render the inserter.
+ if (
+ insertionPointVisible &&
+ blockInsertionPoint?.index === insertionIndex
+ ) {
+ return null;
+ }
return (
{
setInserterIsOpened( {
rootClientId: sectionRootClientId,
- insertionIndex: index + 1,
+ insertionIndex,
tab: 'patterns',
category: 'all',
} );
- showInsertionPoint( sectionRootClientId, index + 1, {
+ showInsertionPoint( sectionRootClientId, insertionIndex, {
operation: 'insert',
} );
} }
From 6e274de2603b44444fb07c1c3afba1354e51e821 Mon Sep 17 00:00:00 2001
From: Andrea Fercia
Date: Tue, 17 Dec 2024 15:31:55 +0100
Subject: [PATCH 59/66] Fix Choose menu label when a menu has been deleted.
(#67009)
* Fix Choose menu label when a menu has been deleted.
* Pass menu ID to useNavigationMenu.
Co-authored-by: afercia
Co-authored-by: ntsekouras
Co-authored-by: draganescu
---
.../src/navigation/edit/navigation-menu-selector.js | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/packages/block-library/src/navigation/edit/navigation-menu-selector.js b/packages/block-library/src/navigation/edit/navigation-menu-selector.js
index dceabf063b26e..0efb597ff8532 100644
--- a/packages/block-library/src/navigation/edit/navigation-menu-selector.js
+++ b/packages/block-library/src/navigation/edit/navigation-menu-selector.js
@@ -61,7 +61,8 @@ function NavigationMenuSelector( {
hasResolvedNavigationMenus,
canUserCreateNavigationMenus,
canSwitchNavigationMenu,
- } = useNavigationMenu();
+ isNavigationMenuMissing,
+ } = useNavigationMenu( currentMenuId );
const [ currentTitle ] = useEntityProp(
'postType',
@@ -106,12 +107,18 @@ function NavigationMenuSelector( {
const noBlockMenus = ! hasNavigationMenus && hasResolvedNavigationMenus;
const menuUnavailable =
hasResolvedNavigationMenus && currentMenuId === null;
+ const navMenuHasBeenDeleted = currentMenuId && isNavigationMenuMissing;
let selectorLabel = '';
if ( isResolvingNavigationMenus ) {
selectorLabel = __( 'Loading…' );
- } else if ( noMenuSelected || noBlockMenus || menuUnavailable ) {
+ } else if (
+ noMenuSelected ||
+ noBlockMenus ||
+ menuUnavailable ||
+ navMenuHasBeenDeleted
+ ) {
// Note: classic Menus may be available.
selectorLabel = __( 'Choose or create a Navigation Menu' );
} else {
From 14d4cda1ce0ef30e1908da49f0a163afbc83a26a Mon Sep 17 00:00:00 2001
From: Dave Smith
Date: Tue, 17 Dec 2024 15:29:38 +0000
Subject: [PATCH 60/66] Correct spelling in Zoom Out Inserters comment (#68051)
* Correct spelling
* Fix caps
Co-authored-by: getdave
Co-authored-by: shail-mehta
Co-authored-by: MaggieCabrera
---
.../src/components/block-tools/zoom-out-mode-inserters.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 96f905293849b..56b8d46b06784 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
@@ -74,7 +74,7 @@ function ZoomOutModeInserters() {
const nextClientId = blockOrder[ insertionIndex ];
// If the block insertion point is visible, and the insertion
- // indicies match then we don't need to render the inserter.
+ // Indices match then we don't need to render the inserter.
if (
insertionPointVisible &&
blockInsertionPoint?.index === insertionIndex
From 9cbbe1db82f350ee437074ffa7ee4b3edfd50821 Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Tue, 17 Dec 2024 18:13:46 +0000
Subject: [PATCH 61/66] Docs: Fix some typos on reference-guide
data-core-block-editor.md.
---
docs/reference-guides/data/data-core-block-editor.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md
index 437f7be20f770..9c937dcb93cbb 100644
--- a/docs/reference-guides/data/data-core-block-editor.md
+++ b/docs/reference-guides/data/data-core-block-editor.md
@@ -448,7 +448,7 @@ Determines the items that appear in the available block transforms list.
Each item object contains what's necessary to display a menu item in the transform list and handle its selection.
-The 'frecency' property is a heuristic () that combines block usage frequenty and recency.
+The 'frecency' property is a heuristic () that combines block usage frequency and recency.
Items are returned ordered descendingly by their 'frecency'.
@@ -521,7 +521,7 @@ _Properties_
- _name_ `string`: The type of block.
- _attributes_ `?Object`: Attributes to pass to the newly created block.
-- _attributesToCopy_ `?Array`: Attributes to be copied from adjecent blocks when inserted.
+- _attributesToCopy_ `?Array`: Attributes to be copied from adjacent blocks when inserted.
### getDraggedBlockClientIds
@@ -580,7 +580,7 @@ Determines the items that appear in the inserter. Includes both static items (e.
Each item object contains what's necessary to display a button in the inserter and handle its selection.
-The 'frecency' property is a heuristic () that combines block usage frequenty and recency.
+The 'frecency' property is a heuristic () that combines block usage frequency and recency.
Items are returned ordered descendingly by their 'utility' and 'frecency'.
From be2238f0decc55d9fa53f001f3fb6cb780c540a1 Mon Sep 17 00:00:00 2001
From: Jorge Costa
Date: Tue, 17 Dec 2024 18:32:52 +0000
Subject: [PATCH 62/66] Revert "Docs: Fix some typos on reference-guide
data-core-block-editor.md."
This reverts commit 9cbbe1db82f350ee437074ffa7ee4b3edfd50821.
---
docs/reference-guides/data/data-core-block-editor.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md
index 9c937dcb93cbb..437f7be20f770 100644
--- a/docs/reference-guides/data/data-core-block-editor.md
+++ b/docs/reference-guides/data/data-core-block-editor.md
@@ -448,7 +448,7 @@ Determines the items that appear in the available block transforms list.
Each item object contains what's necessary to display a menu item in the transform list and handle its selection.
-The 'frecency' property is a heuristic () that combines block usage frequency and recency.
+The 'frecency' property is a heuristic () that combines block usage frequenty and recency.
Items are returned ordered descendingly by their 'frecency'.
@@ -521,7 +521,7 @@ _Properties_
- _name_ `string`: The type of block.
- _attributes_ `?Object`: Attributes to pass to the newly created block.
-- _attributesToCopy_ `?Array`: Attributes to be copied from adjacent blocks when inserted.
+- _attributesToCopy_ `?Array`: Attributes to be copied from adjecent blocks when inserted.
### getDraggedBlockClientIds
@@ -580,7 +580,7 @@ Determines the items that appear in the inserter. Includes both static items (e.
Each item object contains what's necessary to display a button in the inserter and handle its selection.
-The 'frecency' property is a heuristic () that combines block usage frequency and recency.
+The 'frecency' property is a heuristic () that combines block usage frequenty and recency.
Items are returned ordered descendingly by their 'utility' and 'frecency'.
From 028f72d313a03af6ceb159890e74d123977a397c Mon Sep 17 00:00:00 2001
From: Hit Bhalodia <58802366+hbhalodia@users.noreply.github.com>
Date: Wed, 18 Dec 2024 02:25:30 +0530
Subject: [PATCH 63/66] Fix: Add soft deperecation notice for the ButtonGroup
component (#65429)
* Add deperecation notice for the ButtonGroup component
* Address the feedbacks for deprecation for ButtonGroupControl
* Add the changelog deprecation message.
* Update the changelog comment
Co-authored-by: hbhalodia
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: ciampo
---
packages/components/CHANGELOG.md | 1 +
packages/components/src/button-group/README.md | 4 ++++
packages/components/src/button-group/index.tsx | 8 ++++++++
.../components/src/button-group/stories/index.story.tsx | 9 ++++++++-
4 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 7b5ec64bd44ca..dbd1d09fbe690 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -11,6 +11,7 @@
- `TreeSelect`: Deprecate 36px default size ([#67855](https://github.com/WordPress/gutenberg/pull/67855)).
- `SelectControl`: Deprecate 36px default size ([#66898](https://github.com/WordPress/gutenberg/pull/66898)).
- `InputControl`: Deprecate 36px default size ([#66897](https://github.com/WordPress/gutenberg/pull/66897)).
+- Soft deprecate `ButtonGroup` component. Use `ToggleGroupControl` instead ([#65429](https://github.com/WordPress/gutenberg/pull/65429)).
### Bug Fixes
diff --git a/packages/components/src/button-group/README.md b/packages/components/src/button-group/README.md
index 5c0179d6877af..579103dc70e06 100644
--- a/packages/components/src/button-group/README.md
+++ b/packages/components/src/button-group/README.md
@@ -1,5 +1,9 @@
# ButtonGroup
+
+ This component is deprecated. Use `ToggleGroupControl` instead.
+
+
ButtonGroup can be used to group any related buttons together. To emphasize related buttons, a group should share a common container.
![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png)
diff --git a/packages/components/src/button-group/index.tsx b/packages/components/src/button-group/index.tsx
index fb2659c2a0d7d..4bdf3a139188b 100644
--- a/packages/components/src/button-group/index.tsx
+++ b/packages/components/src/button-group/index.tsx
@@ -8,6 +8,7 @@ import type { ForwardedRef } from 'react';
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
+import deprecated from '@wordpress/deprecated';
/**
* Internal dependencies
@@ -22,6 +23,11 @@ function UnforwardedButtonGroup(
const { className, ...restProps } = props;
const classes = clsx( 'components-button-group', className );
+ deprecated( 'wp.components.ButtonGroup', {
+ since: '6.8',
+ alternative: 'wp.components.ToggleGroupControl',
+ } );
+
return (
);
@@ -31,6 +37,8 @@ function UnforwardedButtonGroup(
* ButtonGroup can be used to group any related buttons together. To emphasize
* related buttons, a group should share a common container.
*
+ * @deprecated Use `ToggleGroupControl` instead.
+ *
* ```jsx
* import { Button, ButtonGroup } from '@wordpress/components';
*
diff --git a/packages/components/src/button-group/stories/index.story.tsx b/packages/components/src/button-group/stories/index.story.tsx
index 4b5ab3d5dfdb6..a2df76004d438 100644
--- a/packages/components/src/button-group/stories/index.story.tsx
+++ b/packages/components/src/button-group/stories/index.story.tsx
@@ -9,8 +9,15 @@ import type { Meta, StoryObj } from '@storybook/react';
import ButtonGroup from '..';
import Button from '../../button';
+/**
+ * ButtonGroup can be used to group any related buttons together.
+ * To emphasize related buttons, a group should share a common container.
+ *
+ * This component is deprecated. Use `ToggleGroupControl` instead.
+ */
const meta: Meta< typeof ButtonGroup > = {
- title: 'Components/ButtonGroup',
+ title: 'Components (Deprecated)/ButtonGroup',
+ id: 'components-buttongroup',
component: ButtonGroup,
argTypes: {
children: { control: false },
From 0f0f2a81df00e01bcda28d7b35f2a5ec1070975b Mon Sep 17 00:00:00 2001
From: Lena Morita
Date: Wed, 18 Dec 2024 06:08:51 +0900
Subject: [PATCH 64/66] ActionItem.Slot: Render as `MenuGroup` by default
(#67985)
* ActionItem.Slot: Render as MenuGroup by default
* Add changelog
* Remove redundant `as` rendering in app
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: ciampo
Co-authored-by: ntsekouras
---
packages/editor/src/components/more-menu/index.js | 1 -
packages/editor/src/components/preview-dropdown/index.js | 1 -
packages/interface/CHANGELOG.md | 4 ++++
packages/interface/src/components/action-item/README.md | 4 ++--
packages/interface/src/components/action-item/index.js | 4 ++--
5 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/packages/editor/src/components/more-menu/index.js b/packages/editor/src/components/more-menu/index.js
index 9e062e5e5adc5..f5eaa45e4ed69 100644
--- a/packages/editor/src/components/more-menu/index.js
+++ b/packages/editor/src/components/more-menu/index.js
@@ -113,7 +113,6 @@ export default function MoreMenu() {
diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js
index 6fa35c673430c..a081564e48ea8 100644
--- a/packages/editor/src/components/preview-dropdown/index.js
+++ b/packages/editor/src/components/preview-dropdown/index.js
@@ -190,7 +190,6 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) {
) }
>
diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md
index a0ed9cd83525c..172d70b09fad3 100644
--- a/packages/interface/CHANGELOG.md
+++ b/packages/interface/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Breaking Changes
+
+- `ActionItem.Slot`: Render as `MenuGroup` by default ([#67985](https://github.com/WordPress/gutenberg/pull/67985)).
+
## 8.3.0 (2024-12-11)
## 8.2.0 (2024-11-27)
diff --git a/packages/interface/src/components/action-item/README.md b/packages/interface/src/components/action-item/README.md
index 15c627adfd329..5611e044c8a98 100644
--- a/packages/interface/src/components/action-item/README.md
+++ b/packages/interface/src/components/action-item/README.md
@@ -24,11 +24,11 @@ Property used to change the event bubbling behavior, passed to the `Slot` compon
### as
-The component used as the container of the fills. Defaults to the `ButtonGroup` component.
+The component used as the container of the fills. Defaults to the `MenuGroup` component.
- Type: `Component`
- Required: no
-- Default: `ButtonGroup`
+- Default: `MenuGroup`
## ActionItem
diff --git a/packages/interface/src/components/action-item/index.js b/packages/interface/src/components/action-item/index.js
index 4bd5a11e8d71f..2f3fdd6d3ca30 100644
--- a/packages/interface/src/components/action-item/index.js
+++ b/packages/interface/src/components/action-item/index.js
@@ -1,14 +1,14 @@
/**
* WordPress dependencies
*/
-import { ButtonGroup, Button, Slot, Fill } from '@wordpress/components';
+import { MenuGroup, Button, Slot, Fill } from '@wordpress/components';
import { Children } from '@wordpress/element';
const noop = () => {};
function ActionItemSlot( {
name,
- as: Component = ButtonGroup,
+ as: Component = MenuGroup,
fillProps = {},
bubblesVirtually,
...props
From c5e8fd268328437220798137805c2fb623b25ed1 Mon Sep 17 00:00:00 2001
From: Benazeer Hassan <66269472+benazeer-ben@users.noreply.github.com>
Date: Wed, 18 Dec 2024 02:42:08 +0530
Subject: [PATCH 65/66] Add command to navigate to site editor (#66722)
* Add command to navigate to site editor
* No icon, improved label
* Modified code to display command other than Site editor
* Feedback and suggestion updates
---------
Co-authored-by: benazeer-ben
Co-authored-by: richtabor
Co-authored-by: t-hamano
Co-authored-by: annezazu
Co-authored-by: jameskoster
Co-authored-by: jasmussen
Co-authored-by: ramonjd
---
.../editor/src/components/commands/index.js | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/packages/editor/src/components/commands/index.js b/packages/editor/src/components/commands/index.js
index 0040a09fbdc07..d495dcaaef337 100644
--- a/packages/editor/src/components/commands/index.js
+++ b/packages/editor/src/components/commands/index.js
@@ -25,6 +25,7 @@ import { store as noticesStore } from '@wordpress/notices';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { store as coreStore, useEntityRecord } from '@wordpress/core-data';
import { store as interfaceStore } from '@wordpress/interface';
+import { getPath } from '@wordpress/url';
import { decodeEntities } from '@wordpress/html-entities';
/**
@@ -90,6 +91,19 @@ const getEditorCommandLoader = () =>
const { openModal, enableComplementaryArea, disableComplementaryArea } =
useDispatch( interfaceStore );
const { getCurrentPostId } = useSelect( editorStore );
+ const { isBlockBasedTheme, canCreateTemplate } = useSelect(
+ ( select ) => {
+ return {
+ isBlockBasedTheme:
+ select( coreStore ).getCurrentTheme()?.is_block_theme,
+ canCreateTemplate: select( coreStore ).canUser( 'create', {
+ kind: 'postType',
+ name: 'wp_template',
+ } ),
+ };
+ },
+ []
+ );
const allowSwitchEditorMode =
isCodeEditingEnabled && isRichEditingEnabled;
@@ -271,6 +285,21 @@ const getEditorCommandLoader = () =>
},
} );
}
+ if ( canCreateTemplate && isBlockBasedTheme ) {
+ const isSiteEditor = getPath( window.location.href )?.includes(
+ 'site-editor.php'
+ );
+ if ( ! isSiteEditor ) {
+ commands.push( {
+ name: 'core/go-to-site-editor',
+ label: __( 'Open Site Editor' ),
+ callback: ( { close } ) => {
+ close();
+ document.location = 'site-editor.php';
+ },
+ } );
+ }
+ }
return {
commands,
From 2af83afef495ff205342875af36cef154a46a9ad Mon Sep 17 00:00:00 2001
From: tellthemachines
Date: Wed, 18 Dec 2024 13:38:46 +1100
Subject: [PATCH 66/66] Give style book its own route so it can be linked to
directly. (#67811)
* Give style book its own route so it can be linked to directly.
* Fix paths to and from global styles.
* Use query instead of path
* Fix path
* Effect for editor settings update
---
.../sidebar-global-styles-wrapper/index.js | 73 +++++++------------
.../site-editor-routes/stylebook.js | 4 +-
.../components/site-editor-routes/styles.js | 6 +-
.../src/components/style-book/index.js | 63 ++++++++++++----
4 files changed, 81 insertions(+), 65 deletions(-)
diff --git a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
index 030512a38fab3..de12bbe466bf3 100644
--- a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
+++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js
@@ -6,7 +6,7 @@ import { useMemo, useState } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
import { useViewportMatch } from '@wordpress/compose';
import { Button } from '@wordpress/components';
-import { addQueryArgs } from '@wordpress/url';
+import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
import { seen } from '@wordpress/icons';
/**
@@ -15,15 +15,15 @@ import { seen } from '@wordpress/icons';
import GlobalStylesUI from '../global-styles/ui';
import Page from '../page';
import { unlock } from '../../lock-unlock';
-import StyleBook from '../style-book';
-import { STYLE_BOOK_COLOR_GROUPS } from '../style-book/constants';
const { useLocation, useHistory } = unlock( routerPrivateApis );
const GlobalStylesPageActions = ( {
isStyleBookOpened,
setIsStyleBookOpened,
+ path,
} ) => {
+ const history = useHistory();
return (
{
setIsStyleBookOpened( ! isStyleBookOpened );
+ const updatedPath = ! isStyleBookOpened
+ ? addQueryArgs( path, { preview: 'stylebook' } )
+ : removeQueryArgs( path, 'preview' );
+ // Navigate to the updated path.
+ history.navigate( updatedPath );
} }
size="compact"
/>
);
};
-export default function GlobalStylesUIWrapper() {
+/**
+ * Hook to deal with navigation and location state.
+ *
+ * @return {Array} The current section and a function to update it.
+ */
+export const useSection = () => {
const { path, query } = useLocation();
const history = useHistory();
- const { canvas = 'view' } = query;
- const [ isStyleBookOpened, setIsStyleBookOpened ] = useState( false );
- const isMobileViewport = useViewportMatch( 'medium', '<' );
- const [ section, onChangeSection ] = useMemo( () => {
+ return useMemo( () => {
return [
query.section ?? '/',
( updatedSection ) => {
@@ -55,6 +62,16 @@ export default function GlobalStylesUIWrapper() {
},
];
}, [ path, query.section, history ] );
+};
+
+export default function GlobalStylesUIWrapper() {
+ const { path } = useLocation();
+
+ const [ isStyleBookOpened, setIsStyleBookOpened ] = useState(
+ path.includes( 'preview=stylebook' )
+ );
+ const isMobileViewport = useViewportMatch( 'medium', '<' );
+ const [ section, onChangeSection ] = useSection();
return (
<>
@@ -64,6 +81,7 @@ export default function GlobalStylesUIWrapper() {
) : null
}
@@ -75,45 +93,6 @@ export default function GlobalStylesUIWrapper() {
onPathChange={ onChangeSection }
/>
- { canvas === 'view' && isStyleBookOpened && (
-
- // Match '/blocks/core%2Fbutton' and
- // '/blocks/core%2Fbutton/typography', but not
- // '/blocks/core%2Fbuttons'.
- section ===
- `/blocks/${ encodeURIComponent( blockName ) }` ||
- section.startsWith(
- `/blocks/${ encodeURIComponent( blockName ) }/`
- )
- }
- path={ section }
- onSelect={ ( blockName ) => {
- if (
- STYLE_BOOK_COLOR_GROUPS.find(
- ( group ) => group.slug === blockName
- )
- ) {
- // Go to color palettes Global Styles.
- onChangeSection( '/colors/palette' );
- return;
- }
- if ( blockName === 'typography' ) {
- // Go to typography Global Styles.
- onChangeSection( '/typography' );
- return;
- }
-
- // Now go to the selected block.
- onChangeSection(
- `/blocks/${ encodeURIComponent( blockName ) }`
- );
- } }
- />
- ) }
>
);
}
diff --git a/packages/edit-site/src/components/site-editor-routes/stylebook.js b/packages/edit-site/src/components/site-editor-routes/stylebook.js
index a30c4a7c04945..cb1e414098ab3 100644
--- a/packages/edit-site/src/components/site-editor-routes/stylebook.js
+++ b/packages/edit-site/src/components/site-editor-routes/stylebook.js
@@ -22,7 +22,7 @@ export const stylebookRoute = {
) }
/>
),
- preview: ,
- mobile: ,
+ preview: ,
+ mobile: ,
},
};
diff --git a/packages/edit-site/src/components/site-editor-routes/styles.js b/packages/edit-site/src/components/site-editor-routes/styles.js
index cf29dbebea373..a1827bee76339 100644
--- a/packages/edit-site/src/components/site-editor-routes/styles.js
+++ b/packages/edit-site/src/components/site-editor-routes/styles.js
@@ -10,6 +10,7 @@ import Editor from '../editor';
import { unlock } from '../../lock-unlock';
import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-global-styles';
import GlobalStylesUIWrapper from '../sidebar-global-styles-wrapper';
+import { StyleBookPreview } from '../style-book';
const { useLocation } = unlock( routerPrivateApis );
@@ -30,7 +31,10 @@ export const stylesRoute = {
areas: {
content: ,
sidebar: ,
- preview: ,
+ preview( { query } ) {
+ const isStylebook = query.preview === 'stylebook';
+ return isStylebook ? : ;
+ },
mobile: ,
},
widths: {
diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js
index da69ed734166e..ecbd729ee8a0a 100644
--- a/packages/edit-site/src/components/style-book/index.js
+++ b/packages/edit-site/src/components/style-book/index.js
@@ -32,6 +32,7 @@ import {
useContext,
useRef,
useLayoutEffect,
+ useEffect,
} from '@wordpress/element';
import { ENTER, SPACE } from '@wordpress/keycodes';
@@ -47,6 +48,8 @@ import {
} from './categories';
import { getExamples } from './examples';
import { store as siteEditorStore } from '../../store';
+import { useSection } from '../sidebar-global-styles-wrapper';
+import { STYLE_BOOK_COLOR_GROUPS } from '../style-book/constants';
const {
ExperimentalBlockEditorProvider,
@@ -346,25 +349,55 @@ function StyleBook( {
/**
* Style Book Preview component renders the stylebook without the Editor dependency.
*
- * @param {Object} props Component props.
- * @param {string} props.path Path to the selected block.
- * @param {Object} props.userConfig User configuration.
- * @param {Function} props.isSelected Function to check if a block is selected.
- * @param {Function} props.onSelect Function to select a block.
+ * @param {Object} props Component props.
+ * @param {Object} props.userConfig User configuration.
+ * @param {boolean} props.isStatic Whether the stylebook is static or clickable.
* @return {Object} Style Book Preview component.
*/
-export const StyleBookPreview = ( {
- path = '',
- userConfig = {},
- isSelected,
- onSelect,
-} ) => {
+export const StyleBookPreview = ( { userConfig = {}, isStatic = false } ) => {
const siteEditorSettings = useSelect(
( select ) => select( siteEditorStore ).getSettings(),
[]
);
+
// Update block editor settings because useMultipleOriginColorsAndGradients fetch colours from there.
- dispatch( blockEditorStore ).updateSettings( siteEditorSettings );
+ useEffect( () => {
+ dispatch( blockEditorStore ).updateSettings( siteEditorSettings );
+ }, [ siteEditorSettings ] );
+
+ const [ section, onChangeSection ] = useSection();
+
+ const isSelected = ( blockName ) => {
+ // Match '/blocks/core%2Fbutton' and
+ // '/blocks/core%2Fbutton/typography', but not
+ // '/blocks/core%2Fbuttons'.
+ return (
+ section === `/blocks/${ encodeURIComponent( blockName ) }` ||
+ section.startsWith(
+ `/blocks/${ encodeURIComponent( blockName ) }/`
+ )
+ );
+ };
+
+ const onSelect = ( blockName ) => {
+ if (
+ STYLE_BOOK_COLOR_GROUPS.find(
+ ( group ) => group.slug === blockName
+ )
+ ) {
+ // Go to color palettes Global Styles.
+ onChangeSection( '/colors/palette' );
+ return;
+ }
+ if ( blockName === 'typography' ) {
+ // Go to typography Global Styles.
+ onChangeSection( '/typography' );
+ return;
+ }
+
+ // Now go to the selected block.
+ onChangeSection( `/blocks/${ encodeURIComponent( blockName ) }` );
+ };
const [ resizeObserver, sizes ] = useResizeObserver();
const colors = useMultiOriginPalettes();
@@ -372,7 +405,7 @@ export const StyleBookPreview = ( {
const examplesForSinglePageUse = getExamplesForSinglePageUse( examples );
const { base: baseConfig } = useContext( GlobalStylesContext );
- const goTo = getStyleBookNavigationFromPath( path );
+ const goTo = getStyleBookNavigationFromPath( section );
const mergedConfig = useMemo( () => {
if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) {
@@ -404,8 +437,8 @@ export const StyleBookPreview = ( {
settings={ settings }
goTo={ goTo }
sizes={ sizes }
- isSelected={ isSelected }
- onSelect={ onSelect }
+ isSelected={ ! isStatic ? isSelected : null }
+ onSelect={ ! isStatic ? onSelect : null }
/>