diff --git a/.github/workflows/rnmobile-android-runner.yml b/.github/workflows/rnmobile-android-runner.yml index a57b857aac6234..65bb55e3f63201 100644 --- a/.github/workflows/rnmobile-android-runner.yml +++ b/.github/workflows/rnmobile-android-runner.yml @@ -49,26 +49,28 @@ jobs: - name: Gradle cache uses: gradle/actions/setup-gradle@d156388eb19639ec20ade50009f3d199ce1e2808 # v4.1.0 - - name: AVD cache - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} - - - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 - with: - api-level: ${{ matrix.api-level }} - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - arch: x86_64 - profile: Nexus 6 - script: echo "Generated AVD snapshot for caching." + # AVD cache disabled as it caused emulator termination to hang indefinitely. + # https://github.com/ReactiveCircus/android-emulator-runner/issues/385 + # - name: AVD cache + # uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 + # id: avd-cache + # with: + # path: | + # ~/.android/avd/* + # ~/.android/adb* + # key: avd-${{ matrix.api-level }} + # + # - name: Create AVD and generate snapshot for caching + # if: steps.avd-cache.outputs.cache-hit != 'true' + # uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 + # with: + # api-level: ${{ matrix.api-level }} + # force-avd-creation: false + # emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + # disable-animations: false + # arch: x86_64 + # profile: Nexus 6 + # script: echo "Generated AVD snapshot for caching." - name: Run tests uses: reactivecircus/android-emulator-runner@62dbb605bba737720e10b196cb4220d374026a6d # v2.33.0 diff --git a/backport-changelog/6.8/7069.md b/backport-changelog/6.8/7069.md new file mode 100644 index 00000000000000..ea3c717ec3c93a --- /dev/null +++ b/backport-changelog/6.8/7069.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7069 + +* https://github.com/WordPress/gutenberg/pull/63401 diff --git a/backport-changelog/6.8/7698.md b/backport-changelog/6.8/7698.md new file mode 100644 index 00000000000000..3ded160e7ec449 --- /dev/null +++ b/backport-changelog/6.8/7698.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7698 + +* https://github.com/WordPress/gutenberg/pull/66662 diff --git a/lib/block-supports/typography.php b/lib/block-supports/typography.php index a4719b7bdd4099..21086b94f15c1a 100644 --- a/lib/block-supports/typography.php +++ b/lib/block-supports/typography.php @@ -20,16 +20,16 @@ function gutenberg_register_typography_support( $block_type ) { return; } - $has_font_family_support = $typography_supports['__experimentalFontFamily'] ?? false; + $has_font_family_support = $typography_supports['fontFamily'] ?? false; $has_font_size_support = $typography_supports['fontSize'] ?? false; - $has_font_style_support = $typography_supports['__experimentalFontStyle'] ?? false; - $has_font_weight_support = $typography_supports['__experimentalFontWeight'] ?? false; - $has_letter_spacing_support = $typography_supports['__experimentalLetterSpacing'] ?? false; + $has_font_style_support = $typography_supports['fontStyle'] ?? false; + $has_font_weight_support = $typography_supports['fontWeight'] ?? false; + $has_letter_spacing_support = $typography_supports['letterSpacing'] ?? false; $has_line_height_support = $typography_supports['lineHeight'] ?? false; $has_text_align_support = $typography_supports['textAlign'] ?? false; $has_text_columns_support = $typography_supports['textColumns'] ?? false; - $has_text_decoration_support = $typography_supports['__experimentalTextDecoration'] ?? false; - $has_text_transform_support = $typography_supports['__experimentalTextTransform'] ?? false; + $has_text_decoration_support = $typography_supports['textDecoration'] ?? false; + $has_text_transform_support = $typography_supports['textTransform'] ?? false; $has_writing_mode_support = $typography_supports['__experimentalWritingMode'] ?? false; $has_typography_support = $has_font_family_support @@ -91,16 +91,16 @@ function gutenberg_apply_typography_support( $block_type, $block_attributes ) { return array(); } - $has_font_family_support = $typography_supports['__experimentalFontFamily'] ?? false; + $has_font_family_support = $typography_supports['fontFamily'] ?? false; $has_font_size_support = $typography_supports['fontSize'] ?? false; - $has_font_style_support = $typography_supports['__experimentalFontStyle'] ?? false; - $has_font_weight_support = $typography_supports['__experimentalFontWeight'] ?? false; - $has_letter_spacing_support = $typography_supports['__experimentalLetterSpacing'] ?? false; + $has_font_style_support = $typography_supports['fontStyle'] ?? false; + $has_font_weight_support = $typography_supports['fontWeight'] ?? false; + $has_letter_spacing_support = $typography_supports['letterSpacing'] ?? false; $has_line_height_support = $typography_supports['lineHeight'] ?? false; $has_text_align_support = $typography_supports['textAlign'] ?? false; $has_text_columns_support = $typography_supports['textColumns'] ?? false; - $has_text_decoration_support = $typography_supports['__experimentalTextDecoration'] ?? false; - $has_text_transform_support = $typography_supports['__experimentalTextTransform'] ?? false; + $has_text_decoration_support = $typography_supports['textDecoration'] ?? false; + $has_text_transform_support = $typography_supports['textTransform'] ?? false; $has_writing_mode_support = $typography_supports['__experimentalWritingMode'] ?? false; // Whether to skip individual block support features. diff --git a/lib/class-wp-theme-json-resolver-gutenberg.php b/lib/class-wp-theme-json-resolver-gutenberg.php index 2231cb0f11538f..cd02b5a45c22f7 100644 --- a/lib/class-wp-theme-json-resolver-gutenberg.php +++ b/lib/class-wp-theme-json-resolver-gutenberg.php @@ -920,18 +920,14 @@ public static function resolve_theme_file_uris( $theme_json ) { return $theme_json; } - $resolved_theme_json_data = array( - 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, - ); + $resolved_theme_json_data = $theme_json->get_raw_data(); foreach ( $resolved_urls as $resolved_url ) { $path = explode( '.', $resolved_url['target'] ); _wp_array_set( $resolved_theme_json_data, $path, $resolved_url['href'] ); } - $theme_json->merge( new WP_Theme_JSON_Gutenberg( $resolved_theme_json_data ) ); - - return $theme_json; + return new WP_Theme_JSON_Gutenberg( $resolved_theme_json_data ); } /** diff --git a/lib/compat/wordpress-6.8/blocks.php b/lib/compat/wordpress-6.8/blocks.php new file mode 100644 index 00000000000000..3124dd2a12a615 --- /dev/null +++ b/lib/compat/wordpress-6.8/blocks.php @@ -0,0 +1,47 @@ + 'fontFamily', + '__experimentalFontStyle' => 'fontStyle', + '__experimentalFontWeight' => 'fontWeight', + '__experimentalLetterSpacing' => 'letterSpacing', + '__experimentalTextDecoration' => 'textDecoration', + '__experimentalTextTransform' => 'textTransform', + ); + + $current_typography_supports = $args['supports']['typography']; + $stable_typography_supports = array(); + + foreach ( $current_typography_supports as $key => $value ) { + if ( array_key_exists( $key, $experimental_typography_supports_to_stable ) ) { + $stable_typography_supports[ $experimental_typography_supports_to_stable[ $key ] ] = $value; + } else { + $stable_typography_supports[ $key ] = $value; + } + } + + $args['supports']['typography'] = $stable_typography_supports; + + return $args; +} + +add_filter( 'register_block_type_args', 'gutenberg_stabilize_experimental_block_supports', PHP_INT_MAX, 1 ); diff --git a/lib/load.php b/lib/load.php index 0540d4cd9efac7..6236f0eb04b3c6 100644 --- a/lib/load.php +++ b/lib/load.php @@ -118,6 +118,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.8 compat. require __DIR__ . '/compat/wordpress-6.8/preload.php'; +require __DIR__ . '/compat/wordpress-6.8/blocks.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/package-lock.json b/package-lock.json index 20bf45235880ee..59fb1310a15e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53169,6 +53169,7 @@ "@wordpress/keycodes": "*", "@wordpress/notices": "*", "@wordpress/preferences": "*", + "@wordpress/priority-queue": "*", "@wordpress/private-apis": "*", "@wordpress/rich-text": "*", "@wordpress/style-engine": "*", @@ -54309,7 +54310,6 @@ "@wordpress/plugins": "*", "@wordpress/preferences": "*", "@wordpress/primitives": "*", - "@wordpress/priority-queue": "*", "@wordpress/private-apis": "*", "@wordpress/reusable-blocks": "*", "@wordpress/router": "*", @@ -54994,7 +54994,8 @@ "@wordpress/api-fetch": "*", "@wordpress/blob": "*", "@wordpress/element": "*", - "@wordpress/i18n": "*" + "@wordpress/i18n": "*", + "@wordpress/private-apis": "*" }, "engines": { "node": ">=18.12.0", diff --git a/packages/README.md b/packages/README.md index cc2f34e38ac055..f73aca35786f5a 100644 --- a/packages/README.md +++ b/packages/README.md @@ -43,9 +43,9 @@ When creating a new package, you need to provide at least the following. Package This assumes that your code is located in the `src` folder and will be transpiled with `Babel`. - For packages that should ship as a WordPress script, include `wpScript: true` in the `package.json` file. This tells the build system to bundle the package for use as a WordPress script. + For production packages that will ship as a WordPress script, include `wpScript: true` in the `package.json` file. This tells the build system to bundle the package for use as a WordPress script. - For packages that should ship as a WordPress script module, include a `wpScriptModuleExports` field the `package.json` file. The value of this field can be a string to expose a single script module, or an object with a [shape like the standard `exports` object](https://nodejs.org/docs/latest-v20.x/api/packages.html#subpath-exports) to expose multiple script modules from a single package: + For production packages that will ship as a WordPress script module, include a `wpScriptModuleExports` field in the `package.json` file. The value of this field can be a string to expose a single script module, or an object with a [shape like the standard `exports` object](https://nodejs.org/docs/latest-v20.x/api/packages.html#subpath-exports) to expose multiple script modules from a single package: ```jsonc { @@ -64,7 +64,7 @@ When creating a new package, you need to provide at least the following. Package } ``` - Both `wpScript` and `wpScriptModuleExports` may be included if the package exposes both a script and a script module. + Both `wpScript` and `wpScriptModuleExports` may be included if the package exposes both a script and a script module. These fields are also essential when performing a license check for all their dependencies, because they trigger strict validation against compatibility with GPL v2. All remaining dependencies WordPress doesn't distribute but uses for development purposes can contain also a few other OSS compatible licenses. 1. `README.md` file containing at least: - Package name diff --git a/packages/a11y/package.json b/packages/a11y/package.json index 09fa62a9d082b7..008bd9088e0778 100644 --- a/packages/a11y/package.json +++ b/packages/a11y/package.json @@ -27,8 +27,9 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", - "types": "build-types", + "wpScript": true, "wpScriptModuleExports": "./build-module/module/index.js", + "types": "build-types", "dependencies": { "@babel/runtime": "7.25.7", "@wordpress/dom-ready": "*", @@ -36,6 +37,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/annotations/package.json b/packages/annotations/package.json index 8a94b326a78194..b1d6d210807a87 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "dependencies": { "@babel/runtime": "7.25.7", "@wordpress/data": "*", @@ -38,6 +39,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index 26f156a0aa7d77..7d5b8dfd588897 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "dependencies": { "@babel/runtime": "7.25.7", @@ -34,6 +35,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/autop/package.json b/packages/autop/package.json index 2169ff828da47a..336dda06edfe2c 100644 --- a/packages/autop/package.json +++ b/packages/autop/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -32,6 +33,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/blob/package.json b/packages/blob/package.json index 4b04d2ea2ddebd..0dc01ac7198f59 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -32,6 +33,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index 59025e1ad45c7a..18a40824aa4754 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "dependencies": { "@babel/runtime": "7.25.7", "@wordpress/a11y": "*", @@ -54,6 +55,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 2b5cc8d6275e89..b4672bc57690eb 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "sideEffects": [ "build-style/**", "src/**/*.scss", @@ -59,6 +60,7 @@ "@wordpress/keycodes": "*", "@wordpress/notices": "*", "@wordpress/preferences": "*", + "@wordpress/priority-queue": "*", "@wordpress/private-apis": "*", "@wordpress/rich-text": "*", "@wordpress/style-engine": "*", @@ -87,6 +89,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/block-editor/src/components/block-canvas/style.scss b/packages/block-editor/src/components/block-canvas/style.scss index 0b37b4dd1447e2..8f6064de0b615c 100644 --- a/packages/block-editor/src/components/block-canvas/style.scss +++ b/packages/block-editor/src/components/block-canvas/style.scss @@ -3,7 +3,7 @@ iframe[name="editor-canvas"] { width: 100%; height: 100%; display: block; - background-color: transparent; // Handles transitions between device previews @include editor-canvas-resize-animation; + background-color: $gray-300; } diff --git a/packages/block-editor/src/components/block-inspector/style.scss b/packages/block-editor/src/components/block-inspector/style.scss index e04dfd8e9a480a..dd9d8f254ff76b 100644 --- a/packages/block-editor/src/components/block-inspector/style.scss +++ b/packages/block-editor/src/components/block-inspector/style.scss @@ -10,7 +10,8 @@ margin-bottom: 1.5em; } - .components-base-control { + .components-base-control, + .components-radio-control { &:where(:not(:last-child)) { margin-bottom: $grid-unit-20; } diff --git a/packages/block-editor/src/components/block-patterns-list/README.md b/packages/block-editor/src/components/block-patterns-list/README.md index f63ea449059572..18e7ead5d1805a 100644 --- a/packages/block-editor/src/components/block-patterns-list/README.md +++ b/packages/block-editor/src/components/block-patterns-list/README.md @@ -18,7 +18,6 @@ import { BlockPatternsList } from '@wordpress/block-editor'; const MyBlockPatternsList = () => ( ); @@ -33,13 +32,6 @@ An array of block patterns that can be shown in the block patterns list. - Type: `Array` - Required: Yes -#### shownPatterns - -An array of shown block patterns objects. - -- Type: `Array` -- Required: Yes - #### onClickPattern The performed event after a click on a block pattern. In most cases, the pattern is inserted in the block editor. diff --git a/packages/block-editor/src/components/block-patterns-list/index.js b/packages/block-editor/src/components/block-patterns-list/index.js index 2609cc2db97a13..741a92ddf10dfa 100644 --- a/packages/block-editor/src/components/block-patterns-list/index.js +++ b/packages/block-editor/src/components/block-patterns-list/index.js @@ -134,10 +134,14 @@ function BlockPattern( { } } onMouseLeave={ () => onHover?.( null ) } > - + } + > + + { showTitle && ( - shownPatterns.includes( pattern ) - )?.name; + const firstCompositeItemId = blockPatterns[ 0 ]?.name; setActiveCompositeId( firstCompositeItemId ); - }, [ shownPatterns, blockPatterns ] ); + }, [ blockPatterns ] ); return ( - { blockPatterns.map( ( pattern ) => { - const isShown = shownPatterns.includes( pattern ); - return isShown ? ( - - ) : ( - - ); - } ) } + { blockPatterns.map( ( pattern ) => ( + + ) ) } { pagingProps && } ); diff --git a/packages/block-editor/src/components/block-patterns-list/stories/index.story.js b/packages/block-editor/src/components/block-patterns-list/stories/index.story.js index 9eb393ea13e762..0ebb4520d98fd4 100644 --- a/packages/block-editor/src/components/block-patterns-list/stories/index.story.js +++ b/packages/block-editor/src/components/block-patterns-list/stories/index.story.js @@ -3,11 +3,6 @@ */ import blockLibraryStyles from '!!raw-loader!../../../../../block-library/build-style/style.css'; -/** - * WordPress dependencies - */ -import { useAsyncList } from '@wordpress/compose'; - /** * Internal dependencies */ @@ -26,13 +21,9 @@ export default { export const Default = { render: function Template( props ) { - const shownPatterns = useAsyncList( props.blockPatterns ); return ( - + ); }, diff --git a/packages/block-editor/src/components/block-popover/index.js b/packages/block-editor/src/components/block-popover/index.js index f01c43ef26a71d..06409bc6e65f42 100644 --- a/packages/block-editor/src/components/block-popover/index.js +++ b/packages/block-editor/src/components/block-popover/index.js @@ -20,7 +20,7 @@ import { */ import { useBlockElement } from '../block-list/use-block-props/use-block-refs'; import usePopoverScroll from './use-popover-scroll'; -import { rectUnion, getVisibleElementBounds } from '../../utils/dom'; +import { rectUnion, getElementBounds } from '../../utils/dom'; const MAX_POPOVER_RECOMPUTE_COUNTER = Number.MAX_SAFE_INTEGER; @@ -90,10 +90,10 @@ function BlockPopover( getBoundingClientRect() { return lastSelectedElement ? rectUnion( - getVisibleElementBounds( selectedElement ), - getVisibleElementBounds( lastSelectedElement ) + getElementBounds( selectedElement ), + getElementBounds( lastSelectedElement ) ) - : getVisibleElementBounds( selectedElement ); + : getElementBounds( selectedElement ); }, contextElement: selectedElement, }; diff --git a/packages/edit-site/src/components/async/index.js b/packages/block-editor/src/components/block-preview/async.js similarity index 100% rename from packages/edit-site/src/components/async/index.js rename to packages/block-editor/src/components/block-preview/async.js diff --git a/packages/block-editor/src/components/block-preview/index.js b/packages/block-editor/src/components/block-preview/index.js index 9eef0f1dc2abd5..62b137ff37194b 100644 --- a/packages/block-editor/src/components/block-preview/index.js +++ b/packages/block-editor/src/components/block-preview/index.js @@ -19,6 +19,7 @@ import AutoHeightBlockPreview from './auto'; import EditorStyles from '../editor-styles'; import { store as blockEditorStore } from '../../store'; import { BlockListItems } from '../block-list'; +import { Async } from './async'; const EMPTY_ADDITIONAL_STYLES = []; @@ -86,6 +87,10 @@ export function BlockPreview( { ); } +const MemoizedBlockPreview = memo( BlockPreview ); + +MemoizedBlockPreview.Async = Async; + /** * BlockPreview renders a preview of a block or array of blocks. * @@ -97,7 +102,7 @@ export function BlockPreview( { * * @return {Component} The component to be rendered. */ -export default memo( BlockPreview ); +export default MemoizedBlockPreview; /** * This hook is used to lightly mark an element as a block preview wrapper diff --git a/packages/block-editor/src/components/block-toolbar/change-design.js b/packages/block-editor/src/components/block-toolbar/change-design.js index ecfeff6cb1ed3e..9da1affe4273cc 100644 --- a/packages/block-editor/src/components/block-toolbar/change-design.js +++ b/packages/block-editor/src/components/block-toolbar/change-design.js @@ -10,7 +10,6 @@ import { import { __ } from '@wordpress/i18n'; import { cloneBlock } from '@wordpress/blocks'; import { useMemo } from '@wordpress/element'; -import { useAsyncList } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; /** @@ -81,10 +80,6 @@ export default function ChangeDesign( { clientId } ) { .slice( 0, MAX_PATTERNS_TO_SHOW ); }, [ categories, currentPatternName, patterns ] ); - const currentShownPatterns = useAsyncList( - sameCategoryPatternsWithSingleWrapper - ); - if ( sameCategoryPatternsWithSingleWrapper.length < 2 ) { return null; } @@ -121,7 +116,6 @@ export default function ChangeDesign( { clientId } ) { paddingSize="none" > { destinationClientId ); }, - [ onInsert, getClosestAllowedInsertionPoint, rootClientId ] + [ + getClosestAllowedInsertionPoint, + rootClientId, + onInsert, + createErrorNotice, + ] ); return [ items, categories, collections, onSelectItem ]; diff --git a/packages/block-editor/src/components/inserter/hooks/use-patterns-paging.js b/packages/block-editor/src/components/inserter/hooks/use-patterns-paging.js index 931796acceeb08..cdffef4a00513e 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-patterns-paging.js +++ b/packages/block-editor/src/components/inserter/hooks/use-patterns-paging.js @@ -2,11 +2,10 @@ * WordPress dependencies */ import { useMemo, useState, useEffect } from '@wordpress/element'; -import { useAsyncList, usePrevious } from '@wordpress/compose'; +import { usePrevious } from '@wordpress/compose'; import { getScrollContainer } from '@wordpress/dom'; const PAGE_SIZE = 20; -const INITIAL_INSERTER_RESULTS = 5; /** * Supplies values needed to page the patterns list client side. @@ -42,9 +41,6 @@ export default function usePatternsPaging( pageIndex * PAGE_SIZE + PAGE_SIZE ); }, [ pageIndex, currentCategoryPatterns ] ); - const categoryPatternsAsyncList = useAsyncList( categoryPatterns, { - step: INITIAL_INSERTER_RESULTS, - } ); const numPages = Math.ceil( currentCategoryPatterns.length / PAGE_SIZE ); const changePage = ( page ) => { const scrollContainer = getScrollContainer( @@ -68,7 +64,6 @@ export default function usePatternsPaging( return { totalItems, categoryPatterns, - categoryPatternsAsyncList, numPages, changePage, currentPage, diff --git a/packages/block-editor/src/components/inserter/search-results.js b/packages/block-editor/src/components/inserter/search-results.js index 9c001823745e6c..5a5725a3bb08cd 100644 --- a/packages/block-editor/src/components/inserter/search-results.js +++ b/packages/block-editor/src/components/inserter/search-results.js @@ -159,11 +159,6 @@ function InserterSearchResults( { const currentShownBlockTypes = useAsyncList( filteredBlockTypes, { step: INITIAL_INSERTER_RESULTS, } ); - const currentShownPatterns = useAsyncList( - currentShownBlockTypes.length === filteredBlockTypes.length - ? filteredBlockPatterns - : EMPTY_ARRAY - ); const hasItems = filteredBlockTypes.length > 0 || filteredBlockPatterns.length > 0; @@ -190,7 +185,6 @@ function InserterSearchResults( { >
{ suggestions.map( ( suggestion, index ) => ( ); + // @todo: Temporary fix a bug that prevents Chromium browsers from selecting ".heic" files + // from the file upload. See https://core.trac.wordpress.org/ticket/62268#comment:4. + // This can be removed once the Chromium fix is in the stable channel. + const compatAccept = !! accept?.includes( 'image/*' ) + ? `${ accept }, image/heic, image/heif` + : accept; return (
@@ -56,7 +62,7 @@ export function FormFileUpload( { ref={ ref } multiple={ multiple } style={ { display: 'none' } } - accept={ accept } + accept={ compatAccept } onChange={ onChange } onClick={ onClick } data-testid="form-file-upload-input" diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 2f736d6b95441c..b7cfef7e19a096 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -48,10 +48,21 @@ function useScrollRectIntoView( const childRightEdge = childLeft + childWidth; const rightOverflow = childRightEdge + margin - parentRightEdge; const leftOverflow = parentScroll - ( childLeft - margin ); + + let scrollLeft = null; if ( leftOverflow > 0 ) { - parent.scrollLeft = parentScroll - leftOverflow; + scrollLeft = parentScroll - leftOverflow; } else if ( rightOverflow > 0 ) { - parent.scrollLeft = parentScroll + rightOverflow; + scrollLeft = parentScroll + rightOverflow; + } + + if ( scrollLeft !== null ) { + /** + * The optional chaining is used here to avoid unit test failures. + * It can be removed when JSDOM supports `Element` scroll methods. + * See: https://github.com/WordPress/gutenberg/pull/66498#issuecomment-2441146096 + */ + parent.scroll?.( { left: scrollLeft } ); } }, [ margin, parent, rect ] ); } diff --git a/packages/compose/package.json b/packages/compose/package.json index adff24f6663006..68b00b24298d87 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -27,6 +27,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -49,6 +50,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index ac15b3578cf6ff..db2fb900865c80 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "sideEffects": false, "dependencies": { "@babel/runtime": "7.25.7", @@ -48,6 +49,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 44ed3aaad5dd3a..a7216e931a70ca 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": [ "{src,build,build-module}/index.js" @@ -60,6 +61,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/core-data/src/private-apis.js b/packages/core-data/src/private-apis.js index 443db97957285d..e9fcf36f7e6090 100644 --- a/packages/core-data/src/private-apis.js +++ b/packages/core-data/src/private-apis.js @@ -2,9 +2,11 @@ * Internal dependencies */ import { useEntityRecordsWithPermissions } from './hooks/use-entity-records'; +import { RECEIVE_INTERMEDIATE_RESULTS } from './utils'; import { lock } from './lock-unlock'; export const privateApis = {}; lock( privateApis, { useEntityRecordsWithPermissions, + RECEIVE_INTERMEDIATE_RESULTS, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index ae0c7f456e533d..a35403c0493460 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -21,6 +21,7 @@ import { getUserPermissionCacheKey, getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, + RECEIVE_INTERMEDIATE_RESULTS, } from './utils'; import { getSyncProvider } from './sync'; import { fetchBlockPatterns } from './fetch'; @@ -245,6 +246,14 @@ export const getEntityRecords = { exclusive: false } ); + const key = entityConfig.key || DEFAULT_ENTITY_KEY; + + function getResolutionsArgs( records ) { + return records + .filter( ( record ) => record?.[ key ] ) + .map( ( record ) => [ kind, name, record[ key ] ] ); + } + try { if ( query._fields ) { // If requesting specific fields, items and query association to said @@ -267,7 +276,8 @@ export const getEntityRecords = ...query, } ); - let records, meta; + let records = [], + meta; if ( entityConfig.supportsPagination && query.per_page !== -1 ) { const response = await apiFetch( { path, parse: false } ); records = Object.values( await response.json() ); @@ -279,6 +289,44 @@ export const getEntityRecords = response.headers.get( 'X-WP-TotalPages' ) ), }; + } else if ( + query.per_page === -1 && + query[ RECEIVE_INTERMEDIATE_RESULTS ] === true + ) { + let page = 1; + let totalPages; + + do { + const response = await apiFetch( { + path: addQueryArgs( path, { page, per_page: 100 } ), + parse: false, + } ); + const pageRecords = Object.values( await response.json() ); + + totalPages = parseInt( + response.headers.get( 'X-WP-TotalPages' ) + ); + + records.push( ...pageRecords ); + registry.batch( () => { + dispatch.receiveEntityRecords( + kind, + name, + records, + query + ); + dispatch.finishResolutions( + 'getEntityRecord', + getResolutionsArgs( pageRecords ) + ); + } ); + page++; + } while ( page <= totalPages ); + + meta = { + totalItems: records.length, + totalPages: 1, + }; } else { records = Object.values( await apiFetch( { path } ) ); meta = { @@ -318,11 +366,6 @@ export const getEntityRecords = // See https://github.com/WordPress/gutenberg/pull/26575 // See https://github.com/WordPress/gutenberg/pull/64504 if ( ! query?._fields && ! query.context ) { - const key = entityConfig.key || DEFAULT_ENTITY_KEY; - const resolutionsArgs = records - .filter( ( record ) => record?.[ key ] ) - .map( ( record ) => [ kind, name, record[ key ] ] ); - const targetHints = records .filter( ( record ) => record?.[ key ] ) .map( ( record ) => ( { @@ -356,7 +399,7 @@ export const getEntityRecords = ); dispatch.finishResolutions( 'getEntityRecord', - resolutionsArgs + getResolutionsArgs( records ) ); dispatch.finishResolutions( 'canUser', diff --git a/packages/core-data/src/utils/index.js b/packages/core-data/src/utils/index.js index 189635647779e5..db10359bda07dd 100644 --- a/packages/core-data/src/utils/index.js +++ b/packages/core-data/src/utils/index.js @@ -14,3 +14,4 @@ export { getUserPermissionsFromAllowHeader, ALLOWED_RESOURCE_ACTIONS, } from './user-permissions'; +export { RECEIVE_INTERMEDIATE_RESULTS } from './receive-intermediate-results'; diff --git a/packages/core-data/src/utils/receive-intermediate-results.js b/packages/core-data/src/utils/receive-intermediate-results.js new file mode 100644 index 00000000000000..53d2295b28b390 --- /dev/null +++ b/packages/core-data/src/utils/receive-intermediate-results.js @@ -0,0 +1,3 @@ +export const RECEIVE_INTERMEDIATE_RESULTS = Symbol( + 'RECEIVE_INTERMEDIATE_RESULTS' +); diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index ef6748f34205e8..41b3a7bd463aa3 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -23,6 +23,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "dependencies": { "@babel/runtime": "7.25.7", "@wordpress/block-editor": "*", @@ -55,6 +56,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/data-controls/package.json b/packages/data-controls/package.json index 948efe7fb64022..e4898ff1d0e1ab 100644 --- a/packages/data-controls/package.json +++ b/packages/data-controls/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "dependencies": { "@babel/runtime": "7.25.7", @@ -38,6 +39,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/data/package.json b/packages/data/package.json index 0e46b1a366918e..fd1ef7ef4d7489 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -50,6 +51,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 95b8fc898555c3..ff20386862929e 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -1,10 +1,9 @@ -# DataViews +# The `@wordpress/dataviews` package -DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.). +The DataViews package offers two React components and a few utilites to work with a list of data: -DataViews is data agnostic, it can work with data coming from a static (JSON file) or dynamic source (HTTP Request) — it just requires the data to be an array of objects that have an unique identifier. Consumers are responsible to query the data source appropiately based on the DataViews props: - -![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png "DataViews flow") +- `DataViews`: to render the dataset using different types of layouts (table, grid, list) and interaction capabilities (search, filters, sorting, etc.). +- `DataForm`: to edit the items of the dataset. ## Installation @@ -14,11 +13,21 @@ Install the module npm install @wordpress/dataviews --save ``` -## Usage +## `DataViews` + + + +### Usage + +The `DataViews` component receives data and some other configuration to render the dataset. It'll call the `onChangeView` callback every time the user has interacted with the dataset in some way (sorted, filtered, changed layout, etc.): + +![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png "DataViews flow") + +Example: ```jsx const Example = () => { - // Declare data, fields, etc. + const onChangeView = () => { /* React to user changes. */ } return ( { }; ``` - -## Properties +### Properties -### `data`: `Object[]` +#### `data`: `Object[]` -The dataset to work with, represented as a one-dimensional array. +A one-dimensional array of objects. Example: @@ -58,11 +66,28 @@ const data = [ ]; ``` -By default, dataviews would use each record's `id` as an unique identifier. If it's not, the consumer should provide a `getItemId` function that returns one. +The data can come from anywhere, from a static JSON file to a dynamic source like a HTTP Request. It's the consumer's responsiblity to query the data source appropiately and update the dataset based on the user's choices for sorting, filtering, etc. + +Each record should have an `id` that identifies them uniquely. If they don't, the consumer should provide the `getItemId` property to `DataViews`: a function that returns an unique identifier for the record. + +#### `getItemId`: `function` + +Function that receives an item and returns an unique identifier for it. + +It's optional. The field will get a default implementation by `DataViews` that returns the value of the `item[ id ]`. + +Example: + +```js +// Custom getItemId function. +{ + getItemId={ ( item ) => item.name ?? item.id } +} +``` -### `fields`: `Object[]` +#### `fields`: `Object[]` -The fields describe the visible items for each record in the dataset. +The fields describe the visible items for each record in the dataset and how they behave (how to sort them, display them, etc.). See "Fields API" for a description of every property. Example: @@ -90,7 +115,7 @@ const fields = [ }, { id: 'author', - label: __( 'Author' ), + label: 'Author', render: ( { item } ) => { return { item.author }; }, @@ -104,8 +129,8 @@ const fields = [ enableSorting: false, }, { - label: __( 'Status' ), id: 'status', + label: 'Status', getValue: ( { item } ) => STATUSES.find( ( { value } ) => value === item.status )?.label ?? item.status, @@ -118,29 +143,7 @@ const fields = [ ]; ``` -Each field is an object with the following properties: - -- `id`: identifier for the field. Unique. -- `label`: the field's name to be shown in the UI. -- `getValue`: function that returns the value of the field, defaults to `field[id]`. -- `render`: function that renders the field. Optional, `getValue` will be used if `render` is not defined. -- elements: The list of options to pick from when using the field as a filter or when editing (DataForm component). It expects an array of objects with the following properties: - - - `value`: The id of the value to filter to (for internal use) - - `label`: The text that will be displayed in the UI for the item. - - `description`: A longer description that describes the element, to also be displayed. Optional. - - To enable the filter by a field we just need to set a proper value to the `elements` property of the field we'd like to filter by. - -- `type`: the type of the field. See "Field types". -- `enableSorting`: whether the data can be sorted by the given field. True by default. -- `enableHiding`: whether the field can be hidden. True by default. -- `enableGlobalSearch`: whether the field is searchable. False by default. -- `filterBy`: configuration for the filters enabled by the `elements` property. - - `operators`: the list of [operators](#operators) supported by the field. - - `isPrimary`: whether it is a primary filter. A primary filter is always visible and is not listed in the "Add filter" component, except for the list layout where it behaves like a secondary filter. - -### `view`: `object` +#### `view`: `Object` The view object configures how the dataset is visible to the user. @@ -180,10 +183,10 @@ Properties: - `field`: the field used for sorting the dataset. - `direction`: the direction to use for sorting, one of `asc` or `desc`. -- `fields`: the `id` of the fields that are visible in the UI and the specific order in which they are displayed. +- `fields`: a list of field `id` that are visible in the UI and the specific order in which they are displayed. - `layout`: config that is specific to a particular layout type. -#### Properties of `layout` +##### Properties of `layout` | Properties of `layout` | Table | Grid | List | | --------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---- | @@ -194,11 +197,40 @@ Properties: | `combinedFields`: a list of "virtual" fields that are made by combining others. See "Combining fields" section. | ✓ | | | | `styles`: additional `width`, `maxWidth`, `minWidth` styles for each field column. | ✓ | | | -### `onChangeView`: `function` +##### Combining fields + +The `table` layout has the ability to create "virtual" fields that are made out by combining existing ones. + +Each "virtual field", has to provide an `id` and `label` (optionally a `header` instead), which have the same meaning as any other field. + +Additionally, they need to provide: + +- `children`: a list of field's `id` to combine +- `direction`: how should they be stacked, `vertical` or `horizontal` + +For example, this is how you'd define a `site` field which is a combination of a `title` and `description` fields, which are not displayed: + +```js +{ + fields: [ 'site', 'status' ], + layout: { + combinedFields: [ + { + id: 'site', + label: 'Site', + children: [ 'title', 'description' ], + direction: 'vertical', + } + ] + } +} +``` + +#### `onChangeView`: `function` -The view is a representation of the visible state of the dataset: what type of layout is used to display it (table, grid, etc.), how the dataset is filtered, how it is sorted or paginated. +Callback executed when the view has changed. It receives the new view object as a parameter. -It's the consumer's responsibility to work with the data provider to make sure the user options defined through the view's config (sort, pagination, filters, etc.) are respected. The `onChangeView` prop allows the consumer to provide a callback to be called when the view config changes, to process the data accordingly. +The view is a representation of the visible state of the dataset: what type of layout is used to display it (table, grid, etc.), how the dataset is filtered, how it is sorted or paginated. It's the consumer's responsibility to use the view config to query the data provider and make sure the user decisions (sort, pagination, filters, etc.) are respected. The following example shows how a view object is used to query the WordPress REST API via the entities abstraction. The same can be done with any other data provider. @@ -259,46 +291,44 @@ function MyCustomPageTable() { } ``` -### `actions`: `Object[]` +#### `actions`: `Object[]` Collection of operations that can be performed upon each record. Each action is an object with the following properties: - `id`: string, required. Unique identifier of the action. For example, `move-to-trash`. -- `label`: string|function, required. User facing description of the action. For example, `Move to Trash`. In case we want to adjust the label based on the selected items, a function which accepts the selected records as input can be provided. This function should always return a `string` value. +- `label`: string|function, required. User facing description of the action. For example, `Move to Trash`. It can also take a function that takes the selected items as a parameter and returns a string: this can be useful to provide a dynamic label based on the selection. - `isPrimary`: boolean, optional. Whether the action should be listed inline (primary) or in hidden in the more actions menu (secondary). -- `icon`: icon to show for primary actions. It's required for a primary action, otherwise the action would be considered secondary. +- `icon`: SVG element. Icon to show for primary actions. It's required for a primary action, otherwise the action would be considered secondary. - `isEligible`: function, optional. Whether the action can be performed for a given record. If not present, the action is considered to be eligible for all items. It takes the given record as input. - `isDestructive`: boolean, optional. Whether the action can delete data, in which case the UI would communicate it via red color. -- `callback`: function, required unless `RenderModal` is provided. Callback function that takes the record as input and performs the required action. -- `RenderModal`: ReactElement, optional. If an action requires that some UI be rendered in a modal, it can provide a component which takes as props the record as `item` and a `closeModal` function. When this prop is provided, the `callback` property is ignored. -- `hideModalHeader`: boolean, optional. This property is used in combination with `RenderModal` and controls the visibility of the modal's header. If the action renders a modal and doesn't hide the header, the action's label is going to be used in the modal's header. - `supportsBulk`: Whether the action can be used as a bulk action. False by default. - `disabled`: Whether the action is disabled. False by default. +- `context`: where this action would be visible. One of `list`, `single`. +- `callback`: function, required unless `RenderModal` is provided. Callback function that takes as input the list of items to operate with, and performs the required action. +- `RenderModal`: ReactElement, optional. If an action requires that some UI be rendered in a modal, it can provide a component which takes as input the the list of `items` to operate with, `closeModal` function, and `onActionPerformed` function. When this prop is provided, the `callback` property is ignored. +- `hideModalHeader`: boolean, optional. This property is used in combination with `RenderModal` and controls the visibility of the modal's header. If the action renders a modal and doesn't hide the header, the action's label is going to be used in the modal's header. +- `modalHeader`: string, optional. The header of the modal. -### `paginationInfo`: `Object` +#### `paginationInfo`: `Object` - `totalItems`: the total number of items in the datasets. - `totalPages`: the total number of pages, taking into account the total items in the dataset and the number of items per page provided by the user. -### `search`: `boolean` +#### `search`: `boolean` Whether the search input is enabled. `true` by default. -### `searchLabel`: `string` +#### `searchLabel`: `string` What text to show in the search input. "Search" by default. -### `getItemId`: `function` - -Function that receives an item and returns an unique identifier for it. By default, it uses the `id` of the item as unique identifier. If it's not, the consumer should provide their own. - -### `isLoading`: `boolean` +#### `isLoading`: `boolean` Whether the data is loading. `false` by default. -### `defaultLayouts`: `Record< string, view >` +#### `defaultLayouts`: `Record< string, view >` This property provides layout information about the view types that are active. If empty, enables all layout types (see "Layout Types") with empty layout data. @@ -314,56 +344,498 @@ const defaultLayouts = { }; ``` -The `defaultLayouts` property should be an object that includes properties named `table`, `grid`, or `list`. Each of these properties should contain a `layout` property, which holds the configuration for each specific layout type. Check [here](#properties-of-layout) the full list of properties available for each layout's configuration +The `defaultLayouts` property should be an object that includes properties named `table`, `grid`, or `list`. Each of these properties should contain a `layout` property, which holds the configuration for each specific layout type. Check "Properties of layout" for the full list of properties available for each layout's configuration -### `onChangeSelection`: `function` +#### `selection`: `string[]` -Callback that signals the user selected one of more items, and takes them as parameter. So far, only the `list` view implements it. +The list of selected items' ids. -## Types +If `selection` and `onChangeSelection` are provided, the `DataViews` component behaves as a controlled component, otherwise, it behaves like an uncontrolled component. -### Layouts +#### `onChangeSelection`: `function` -- `table`: the view uses a table layout. -- `grid`: the view uses a grid layout. -- `list`: the view uses a list layout. +Callback that signals the user selected one of more items. It receives the list of selected items' ids as a parameter. -### Fields +If `selection` and `onChangeSelection` are provided, the `DataViews` component behaves as a controlled component, otherwise, it behaves like an uncontrolled component. -> The `enumeration` type was removed as it was deemed redundant with the field.elements metadata. New types will be introduced soon. +#### `header`: React component -## Combining fields +React component to be rendered next to the view config button. -The `table` layout has the ability to create "virtual" fields that are made out by combining existing ones. +## `DataForm` -Each "virtual field", has to provide an `id` and `label` (optionally a `header` instead), which have the same meaning as any other field. + -Additionally, they need to provide: +### Usage -- `children`: a list of field's `id` to combine -- `direction`: how should they be stacked, `vertical` or `horizontal` +```jsx +const Example = () => { + // Declare data, fields, etc. -For example, this is how you'd define a `site` field which is a combination of a `title` and `description` fields, which are not displayed: + return ( + + ) +} +``` + +### Properties + +#### `data`: `Object` + +A single item to be edited. + +It can be think of as a single record coming from the `data` property of `DataViews` — though it doesn't need to be. It can be totally separated or a mix of records if your app supports bulk editing. + +#### `fields`: `Object[]` + +The fields describe which parts of the data are visible and how they behave (how to edit them, validate them, etc.). See "Fields API" for a description of every property. + +Example: + +```js +const fields = [ + { + id: 'title', + type: 'text', + label: 'Title', + }, + { + id: 'date', + type: 'datetime', + label: 'Date', + }, + { + id: 'author', + type: 'text' + label: 'Author', + elements: [ + { value: 1, label: 'Admin' }, + { value: 2, label: 'User' }, + ], + }, +]; +``` + +#### `form`: `Object[]` + +- `type`: either `regular` or `panel`. +- `fields`: a list of fields ids that should be rendered. + +#### `onChange`: `function` + +Callback function that receives an object with the edits done by the user. + +Example: + +```js +const data = { + id: 1, + title: 'Title', + author: 'Admin', + date: '2012-04-23T18:25:43.511Z', +}; + +const onChange = ( edits ) => { + /* + * edits will contain user edits. + * For example, if the user edited the title + * edits will be: + * + * { + * title: 'New title' + * } + * + */ +}; + +return ( + +); +``` + +## Utilities + +### `filterSortAndPaginate` + +Utility to apply the view config (filters, search, sorting, and pagination) to a dataset client-side. + +Parameters: + +- `data`: the dataset, as described in the "data" property of DataViews. +- `view`: the view config, as described in the "view" property of DataViews. +- `fields`: the fields config, as described in the "fields" property of DataViews. + +Returns an object containing: + +- `data`: the new dataset, with the view config applied. +- `paginationInfo`: object containing the following properties: + - `totalItems`: total number of items for the current view config. + - `totalPages`: total number of pages for the current view config. + +### `isItemValid` + +Utility to determine whether or not the given item's value is valid according to the current fields and form config. + +Parameters: + +- `item`: the item, as described in the "data" property of DataForm. +- `fields`: the fields config, as described in the "fields" property of DataForm. +- `form`: the form config, as described in the "form" property of DataForm. + +Returns a boolean indicating if the item is valid (true) or not (false). + +## Fields API + +### `id` + +The unique identifier of the field. + +- Type: `string`. +- Required. + +Example: + +```js +{ id: 'field_id' } +``` + +### `type` + +Field type. One of `text`, `integer`, `datetime`. + +If a field declares a `type`, it gets default implementations for the `sort`, `isValid`, and `Edit` functions. They will overriden if the field provides its own. + +- Type: `string`. +- Optional. + +Example: + +```js +{ type: 'text' } +``` + +### `label` + +The field's name. This will be used across the UI. + +- Type: `string`. +- Optional. +- Defaults to the `id` value. + +Example: + +```js +{ label: 'Title' } +``` + +### `header` + +React component used by the layouts to display the field name — useful to add icons, etc. It's complementary to the `label` property. + +- Type: React component. +- Optional. +- Defaults to the `label` value. +- Props: none. +- Returns a React element that represents the field's name. + +Example: ```js { - fields: [ 'site', 'status' ], - layout: { - combinedFields: [ - { - id: 'site', - label: 'Site', - children: [ 'title', 'description' ], - direction: 'vertical', - } - ] + header: () => { /* Returns a react element. */ } +} +``` + +### `getValue` + +React component that returns the value of a field. This value is used in sorting the fields, or when filtering. + +- Type: React component. +- Optional. +- Defaults to `item[ id ]`. +- Props: + - `item` value to be processed. +- Returns a value that represents the field. + +Example: + +```js +{ + getValue: ( { item } ) => { /* The field's value. */ }; +} +``` + +### `render` + +React component that renders the field. This is used by the layouts. + +- Type: React component. +- Optional. +- Defaults to `getValue`. +- Props + - `item` value to be processed. +- Returns a React element that represents the field's value. + +Example: + +```js +{ + render: ( { item} ) => { /* React element to be displayed. */ } +} +``` + +### `Edit` + +React component that renders the control to edit the field. + +- Type: React component | `string`. If it's a string, it needs to be one of `text`, `integer`, `datetime`, `radio`, `select`. +- Required by DataForm. Optional if the field provided a `type`. +- Props: + - `data`: the item to be processed + - `field`: the field definition + - `onChange`: the callback with the updates + - `hideLabelFromVision`: boolean representing if the label should be hidden +- Returns a React element to edit the field's value. + +Example: + +```js +// A custom control defined by the field. +{ + Edit: ( { + data, + field, + onChange, + hideLabelFromVision + } ) => { + const value = field.getValue( { item: data } ); + + return ( + + ); + } +} +``` + +```js +// Use one of the core controls. +{ + Edit: 'radio' +} +``` + +```js +// Edit is optional when field's type is present. +// The field will use the default Edit function for text. +{ + type: 'text' +} +``` + +```js +// Edit can be provided even if field's type is present. +// The field will use its own custom control. +{ + type: 'text', + Edit: 'radio' +} +``` + +### `sort` + +Function to sort the records. + +- Type: `function`. +- Optional. +- Args + - `a`: the first item to compare + - `b`: the second item to compare + - `direction`: either `asc` (ascending) or `desc` (descending) +- Returns a number where: + - a negative value indicates that `a` should come before `b` + - a positive value indicates that `a` should come after `b` + - 0 indicates that `a` and `b` are considered equal + +Example: + +```js +// A custom sort function defined by the field. +{ + sort: ( a, b, direction ) => { + return direction === 'asc' + ? a.localeCompare( b ) + : b.localeCompare( a ); } } ``` -### Operators +```js +// If field type is provided, +// the field gets a default sort function. +{ + type: 'number' +} +``` + +```js +// Even if a field type is provided, +// fields can override the default sort function assigned for that type. +{ + type: 'number' + sort: ( a, b, direction ) => { /* Custom sort */ } +} +``` + +### `isValid` + +Function to validate a field's value. + +- Type: function. +- Optional. +- Args + - `item`: the data to validate + - `context`: an object containing the following props: + - `elements`: the elements defined by the field +- Returns a boolean, indicating if the field is valid or not. -Allowed operators: +Example: + +```js +// Custom isValid function. +{ + isValid: ( item, context ) => { + return !! item; + } +} +``` + +```js +// If the field defines a type, +// it'll get a default isValid function for the type. +{ + type: 'number', +} +``` + +```js +// Even if the field provides a type, +// the field can override the default isValid function. +{ + type: 'number', + isValid: ( item, context ) => { /* Custom function. */ } +} +``` + +### `isVisible` + +Function that indicates if the field should be visible. + +- Type: `function`. +- Optional. +- Args + - `item`: the data to be processed +- Returns a `boolean` indicating if the field should be visible (`true`) or not (`false`). + +Example: + +```js +// Custom isVisible function. +{ + isVisible: ( item ) => { /* Custom implementation. */ } +} +``` + +### `enableSorting` + +Boolean indicating if the field is sortable. + +- Type: `boolean`. +- Optional. +- Defaults to `true`. + +Example: + +```js +{ enableSorting: true } +``` + +### `enableHiding` + +Boolean indicating if the field can be hidden. + +- Type: `boolean`. +- Optional. +- Defaults to `true`. + +Example: + +```js +{ enableHiding: true } +``` + +### `enableGlobalSearch` + +Boolean indicating if the field is searchable. + +- Type: `boolean`. +- Optional. +- Defaults to `false`. + +Example: + +```js +{ enableGlobalSearch: true } +``` + +### `elements` + +List of valid values for a field. If provided, it creates a DataViews' filter for the field. DataForm's edit control will use these values as well (see `Edit` field property). + +- Type: `array` of objects. +- Optional. +- Each object can have the following properties: + - `value`: required, the value to match against the field's value. + - `label`: required, the name to display to users. + - `description`: optional, a longer description of the item. + +Example: + +```js +{ + elements: [ + { value: '1', label: 'Product A' }, + { value: '2', label: 'Product B' }, + { value: '3', label: 'Product C' }, + { value: '4', label: 'Product D' }, + ] +} +``` + +### `filterBy` + +Configuration of the filters. + +- Type: `object`. +- Optional. +- Properties: + - `operators`: the list of operators supported by the field. See "operators" below. By default, a filter will support the `isAny` and `isNone` multi-selection operators. + - `isPrimary`: boolean, optional. Indicates if the filter is primary. A primary filter is always visible and is not listed in the "Add filter" component, except for the list layout where it behaves like a secondary filter. + +Operators: | Operator | Selection | Description | Example | | ---------- | -------------- | ----------------------------------------------------------------------- | -------------------------------------------------- | @@ -376,7 +848,34 @@ Allowed operators: `is` and `isNot` are single-selection operators, while `isAny`, `isNone`, `isAll`, and `isNotALl` are multi-selection. By default, a filter with no operators declared will support the `isAny` and `isNone` multi-selection operators. A filter cannot mix single-selection & multi-selection operators; if a single-selection operator is present in the list of valid operators, the multi-selection ones will be discarded and the filter won't allow selecting more than one item. -> The legacy operators `in` and `notIn` have been deprecated and will be removed soon. In the meantime, they work as `is` and `isNot` operators, respectively. +Example: + +```js +// Set a filter as primary. +{ + filterBy: { + isPrimary: true + } +} +``` + +```js +// Configure a filter as single-selection. +{ + filterBy: { + operators: [ `is`, `isNot` ] + } +} +``` + +```js +// Configure a filter as multi-selection with all the options. +{ + filterBy: { + operators: [ `isAny`, `isNone`, `isAll`, `isNotAll` ] + } +} +``` ## Contributing to this package diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 5599a84b92d34c..df30fea1a1c714 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -47,6 +48,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/dataviews/src/validation.ts b/packages/dataviews/src/validation.ts index 41969a7960af65..0a6542da4e8d40 100644 --- a/packages/dataviews/src/validation.ts +++ b/packages/dataviews/src/validation.ts @@ -4,6 +4,15 @@ import { normalizeFields } from './normalize-fields'; import type { Field, Form } from './types'; +/** + * Whether or not the given item's value is valid according to the fields and form config. + * + * @param item The item to validate. + * @param fields Fields config. + * @param form Form config. + * + * @return A boolean indicating if the item is valid (true) or not (false). + */ export function isItemValid< Item >( item: Item, fields: Field< Item >[], diff --git a/packages/date/package.json b/packages/date/package.json index f53c6314912271..d67c1dc527caee 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "dependencies": { "@babel/runtime": "7.25.7", @@ -34,6 +35,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/deprecated/package.json b/packages/deprecated/package.json index 69d2414b0c11a2..64ffc6cd30b251 100644 --- a/packages/deprecated/package.json +++ b/packages/deprecated/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -33,6 +34,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json index 0202229b202842..6e7986c4965dc6 100644 --- a/packages/dom-ready/package.json +++ b/packages/dom-ready/package.json @@ -25,6 +25,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -32,6 +33,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/dom/package.json b/packages/dom/package.json index 58c572545cc526..97576e9a22e0ff 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { @@ -34,6 +35,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index c594722d180e4f..1b28c1d5f31aa6 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "dependencies": { "@babel/runtime": "7.25.7", "@wordpress/a11y": "*", @@ -66,6 +67,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 95f9cd5f05742a..41bf39c1e08332 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -26,6 +26,7 @@ "main": "build/index.js", "module": "build-module/index.js", "react-native": "src/index", + "wpScript": true, "dependencies": { "@babel/runtime": "7.25.7", "@react-spring/web": "^9.4.5", @@ -60,7 +61,6 @@ "@wordpress/plugins": "*", "@wordpress/preferences": "*", "@wordpress/primitives": "*", - "@wordpress/priority-queue": "*", "@wordpress/private-apis": "*", "@wordpress/reusable-blocks": "*", "@wordpress/router": "*", @@ -82,6 +82,5 @@ }, "publishConfig": { "access": "public" - }, - "wpScript": true + } } diff --git a/packages/edit-site/src/components/block-editor/use-editor-iframe-props.js b/packages/edit-site/src/components/block-editor/use-editor-iframe-props.js index 46719a00c16aad..7c88fee0d5b727 100644 --- a/packages/edit-site/src/components/block-editor/use-editor-iframe-props.js +++ b/packages/edit-site/src/components/block-editor/use-editor-iframe-props.js @@ -60,11 +60,10 @@ export default function useEditorIframeProps() { } ); } }, - onClick: () => { + onClick: () => history.push( { ...params, canvas: 'edit' }, undefined, { transition: 'canvas-mode-edit-transition', - } ); - }, + } ), onClickCapture: ( event ) => { if ( currentPostIsTrashed ) { event.preventDefault(); diff --git a/packages/edit-site/src/components/editor-canvas-container/style.scss b/packages/edit-site/src/components/editor-canvas-container/style.scss index 0bdbc2bbe32355..52ac29da0696f6 100644 --- a/packages/edit-site/src/components/editor-canvas-container/style.scss +++ b/packages/edit-site/src/components/editor-canvas-container/style.scss @@ -1,6 +1,5 @@ .edit-site-editor-canvas-container { height: 100%; - background-color: $gray-300; // Controls height of editor and editor canvas container (style book, global styles revisions previews etc.) iframe { 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 127480ee5af497..f26a8a5ed17436 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 @@ -215,8 +215,10 @@ export default function ShadowsEditPanel() { size="medium" > { sprintf( - // translators: %s: name of the shadow - 'Are you sure you want to delete "%s"?', + /* translators: %s: Name of the shadow preset. */ + __( + 'Are you sure you want to delete "%s" shadow preset?' + ), selectedShadow.name ) } diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index 9ca88f40f1f001..2edea0fdbc3da3 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -19,7 +19,8 @@ import { __ } from '@wordpress/i18n'; import { store as preferencesStore } from '@wordpress/preferences'; import { moreVertical } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; -import { useEffect } from '@wordpress/element'; +import { useEffect, Fragment } from '@wordpress/element'; +import { usePrevious } from '@wordpress/compose'; /** * Internal dependencies @@ -291,18 +292,52 @@ function GlobalStylesEditorCanvasContainerLink() { }, [ editorCanvasContainerView, isRevisionsOpen, goTo ] ); } -function GlobalStylesUI() { +function useNavigatorSync( parentPath, onPathChange ) { + const navigator = useNavigator(); + const { path: childPath } = navigator.location; + const previousParentPath = usePrevious( parentPath ); + const previousChildPath = usePrevious( childPath ); + useEffect( () => { + if ( parentPath !== childPath ) { + if ( parentPath !== previousParentPath ) { + navigator.goTo( parentPath ); + } else if ( childPath !== previousChildPath ) { + onPathChange( childPath ); + } + } + }, [ + onPathChange, + parentPath, + previousChildPath, + previousParentPath, + childPath, + navigator, + ] ); +} + +// This component is used to wrap the hook in order to conditionally execute it +// when the parent component is used on controlled mode. +function NavigationSync( { path: parentPath, onPathChange, children } ) { + useNavigatorSync( parentPath, onPathChange ); + return children; +} + +function GlobalStylesUI( { path, onPathChange } ) { const blocks = getBlockTypes(); const editorCanvasContainerView = useSelect( ( select ) => unlock( select( editSiteStore ) ).getEditorCanvasContainerView(), [] ); + return ( + { path && onPathChange && ( + + ) } diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index 551d1448fde5c9..cbc0a4661bf3e7 100644 --- a/packages/edit-site/src/components/layout/index.js +++ b/packages/edit-site/src/components/layout/index.js @@ -125,7 +125,12 @@ export default function Layout( { route } ) { isResizableFrameOversized } /> - + { areas.sidebar } diff --git a/packages/edit-site/src/components/page-patterns/fields.js b/packages/edit-site/src/components/page-patterns/fields.js index 74433b3de72f0e..0ad47e90c20402 100644 --- a/packages/edit-site/src/components/page-patterns/fields.js +++ b/packages/edit-site/src/components/page-patterns/fields.js @@ -25,7 +25,6 @@ import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ -import { Async } from '../async'; import { PATTERN_TYPES, TEMPLATE_PART_POST_TYPE, @@ -88,12 +87,12 @@ function PreviewField( { item } ) { { isEmpty && isTemplatePart && __( 'Empty template part' ) } { isEmpty && ! isTemplatePart && __( 'Empty pattern' ) } { ! isEmpty && ( - + - + ) } { !! description && ( diff --git a/packages/edit-site/src/components/page-templates/fields.js b/packages/edit-site/src/components/page-templates/fields.js index d26f1906a10664..69e0596bf49d47 100644 --- a/packages/edit-site/src/components/page-templates/fields.js +++ b/packages/edit-site/src/components/page-templates/fields.js @@ -20,7 +20,6 @@ import { EditorProvider } from '@wordpress/editor'; /** * Internal dependencies */ -import { Async } from '../async'; import { default as Link, useLink } from '../routes/link'; import { useAddedBy } from './hooks'; @@ -63,9 +62,9 @@ function PreviewField( { item } ) { > { isEmpty && __( 'Empty template' ) } { ! isEmpty && ( - + - + ) }
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 new file mode 100644 index 00000000000000..afa9f489dde22b --- /dev/null +++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js @@ -0,0 +1,145 @@ +/** + * WordPress dependencies + */ +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'; + +/** + * Internal dependencies + */ +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 { Menu } = unlock( componentsPrivateApis ); +const GLOBAL_STYLES_PATH_PREFIX = '/wp_global_styles'; + +const GlobalStylesPageActions = ( { + isStyleBookOpened, + setIsStyleBookOpened, +} ) => { + return ( + + { __( 'Preview' ) } + + } + > + setIsStyleBookOpened( true ) } + defaultChecked + > + { __( 'Style book' ) } + + { __( 'Preview blocks and styles.' ) } + + + setIsStyleBookOpened( false ) } + > + { __( 'Site' ) } + + { __( 'Preview your site.' ) } + + + + ); +}; + +export default function GlobalStylesUIWrapper() { + const { params } = useLocation(); + const history = useHistory(); + const { canvas = 'view' } = params; + const [ isStyleBookOpened, setIsStyleBookOpened ] = useState( false ); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const pathWithPrefix = params.path; + const [ path, onPathChange ] = useMemo( () => { + const processedPath = pathWithPrefix.substring( + GLOBAL_STYLES_PATH_PREFIX.length + ); + return [ + processedPath ? processedPath : '/', + ( newPath ) => { + history.push( { + path: + ! newPath || newPath === '/' + ? GLOBAL_STYLES_PATH_PREFIX + : `${ GLOBAL_STYLES_PATH_PREFIX }${ newPath }`, + } ); + }, + ]; + }, [ pathWithPrefix, history ] ); + + return ( + <> + + ) : null + } + className="edit-site-styles" + title={ __( 'Styles' ) } + > + + + { canvas === 'view' && isStyleBookOpened && ( + + // Match '/blocks/core%2Fbutton' and + // '/blocks/core%2Fbutton/typography', but not + // '/blocks/core%2Fbuttons'. + path === + `/wp_global_styles/blocks/${ encodeURIComponent( + blockName + ) }` || + path.startsWith( + `/wp_global_styles/blocks/${ encodeURIComponent( + blockName + ) }/` + ) + } + path={ path } + onSelect={ ( blockName ) => { + if ( + STYLE_BOOK_COLOR_GROUPS.find( + ( group ) => group.slug === blockName + ) + ) { + // Go to color palettes Global Styles. + onPathChange( '/colors/palette' ); + return; + } + + // Now go to the selected block. + onPathChange( + `/blocks/${ encodeURIComponent( blockName ) }` + ); + } } + /> + ) } + + ); +} 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 new file mode 100644 index 00000000000000..88aa9ddf0c1618 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss @@ -0,0 +1,35 @@ +.edit-site-styles .edit-site-page-content { + .edit-site-global-styles-screen-root { + box-shadow: none; + & > div > hr { + display: none; + } + } + .edit-site-global-styles-sidebar__navigator-provider { + .components-tools-panel { + border-top: none; + } + overflow-y: auto; + padding-left: 0; + padding-right: 0; + + .edit-site-global-styles-sidebar__navigator-screen { + padding-top: $grid-unit-15; + padding-left: $grid-unit-15; + padding-right: $grid-unit-15; + padding-bottom: $grid-unit-15; + outline: none; + } + } + .edit-site-page-header { + padding-left: $grid-unit-60; + padding-right: $grid-unit-60; + @container (max-width: 430px) { + padding-left: $grid-unit-30; + padding-right: $grid-unit-30; + } + } + .edit-site-sidebar-button { + color: $gray-900; + } +} 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 016027ef715a45..202de5300076c1 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -7,7 +7,7 @@ &:hover, &:focus, - &[aria-current] { + &[aria-current="true"] { color: $gray-200; background: $gray-800; @@ -16,7 +16,7 @@ } } - &[aria-current] { + &[aria-current="true"] { background: var(--wp-admin-theme-color); color: $white; } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js index 6579107a60e55f..3dc93ff4d4df63 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-global-styles/index.js @@ -2,12 +2,9 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { edit, seen } from '@wordpress/icons'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { useViewportMatch } from '@wordpress/compose'; import { useCallback } from '@wordpress/element'; -import { store as editorStore } from '@wordpress/editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { privateApis as routerPrivateApis } from '@wordpress/router'; @@ -17,18 +14,14 @@ import { privateApis as routerPrivateApis } from '@wordpress/router'; import SidebarNavigationScreen from '../sidebar-navigation-screen'; import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; -import SidebarButton from '../sidebar-button'; import SidebarNavigationItem from '../sidebar-navigation-item'; -import StyleBook from '../style-book'; import useGlobalStylesRevisions from '../global-styles/screen-revisions/use-global-styles-revisions'; import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-details-footer'; -import SidebarNavigationScreenGlobalStylesContent from './content'; +import { MainSidebarNavigationContent } from '../sidebar-navigation-screen-main'; const { useLocation, useHistory } = unlock( routerPrivateApis ); export function SidebarNavigationItemGlobalStyles( props ) { - const { openGeneralSidebar } = useDispatch( editSiteStore ); - const history = useHistory(); const { params } = useLocation(); const hasGlobalStyleVariations = useSelect( ( select ) => @@ -43,47 +36,25 @@ export function SidebarNavigationItemGlobalStyles( props ) { { ...props } params={ { path: '/wp_global_styles' } } uid="global-styles-navigation-item" + aria-current={ + params.path && params.path.startsWith( '/wp_global_styles' ) + } /> ); } - return ( - { - // Switch to edit mode. - history.push( - { - ...params, - canvas: 'edit', - }, - undefined, - { - transition: 'canvas-mode-edit-transition', - } - ); - // Open global styles sidebar. - openGeneralSidebar( 'edit-site/global-styles' ); - } } - /> - ); + return ; } -export default function SidebarNavigationScreenGlobalStyles( { backPath } ) { +export default function SidebarNavigationScreenGlobalStyles() { const history = useHistory(); const { params } = useLocation(); - const { canvas = 'view' } = params; const { revisions, isLoading: isLoadingRevisions } = useGlobalStylesRevisions(); const { openGeneralSidebar } = useDispatch( editSiteStore ); - const { setIsListViewOpened } = useDispatch( editorStore ); - const isMobileViewport = useViewportMatch( 'medium', '<' ); const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) ); - const { isStyleBookOpened, revisionsCount } = useSelect( ( select ) => { - const { getEditorCanvasContainerView } = unlock( - select( editSiteStore ) - ); + const { revisionsCount } = useSelect( ( select ) => { const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = select( coreStore ); const globalStylesId = __experimentalGetCurrentGlobalStylesId(); @@ -91,7 +62,6 @@ export default function SidebarNavigationScreenGlobalStyles( { backPath } ) { ? getEntityRecord( 'root', 'globalStyles', globalStylesId ) : undefined; return { - isStyleBookOpened: 'style-book' === getEditorCanvasContainerView(), revisionsCount: globalStyles?._links?.[ 'version-history' ]?.[ 0 ]?.count ?? 0, }; @@ -115,19 +85,6 @@ export default function SidebarNavigationScreenGlobalStyles( { backPath } ) { ] ); }, [ history, params, openGeneralSidebar, setPreference ] ); - const openStyleBook = useCallback( async () => { - await openGlobalStyles(); - // Open the Style Book once the canvas mode is set to edit, - // and the global styles sidebar is open. This ensures that - // the Style Book is not prematurely closed. - setEditorCanvasContainerView( 'style-book' ); - setIsListViewOpened( false ); - }, [ - openGlobalStyles, - setEditorCanvasContainerView, - setIsListViewOpened, - ] ); - const openRevisions = useCallback( async () => { await openGlobalStyles(); // Open the global styles revisions once the canvas mode is set to edit, @@ -142,16 +99,17 @@ export default function SidebarNavigationScreenGlobalStyles( { backPath } ) { const modifiedDateTime = revisions?.[ 0 ]?.modified; const shouldShowGlobalStylesFooter = hasRevisions && ! isLoadingRevisions && modifiedDateTime; - return ( <> } + content={ + + } footer={ shouldShowGlobalStylesFooter && ( ) } - actions={ - <> - { ! isMobileViewport && ( - - setEditorCanvasContainerView( - ! isStyleBookOpened - ? 'style-book' - : undefined - ) - } - isPressed={ isStyleBookOpened } - /> - ) } - await openGlobalStyles() } - /> - - } /> - { isStyleBookOpened && ! isMobileViewport && canvas === 'view' && ( - false } - onClick={ openStyleBook } - onSelect={ openStyleBook } - showCloseButton={ false } - showTabs={ false } - /> - ) } ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js index bdfb6ac93b51c4..49e60d44047326 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js @@ -21,6 +21,51 @@ import { PATTERN_TYPES, } from '../../utils/constants'; +export function MainSidebarNavigationContent() { + return ( + + + { __( 'Navigation' ) } + + + { __( 'Styles' ) } + + + { __( 'Pages' ) } + + + { __( 'Templates' ) } + + + { __( 'Patterns' ) } + + + ); +} + export default function SidebarNavigationScreenMain() { const { setEditorCanvasContainerView } = unlock( useDispatch( editSiteStore ) @@ -38,51 +83,7 @@ export default function SidebarNavigationScreenMain() { description={ __( 'Customize the appearance of your website using the block editor.' ) } - content={ - <> - - - { __( 'Navigation' ) } - - - { __( 'Styles' ) } - - - { __( 'Pages' ) } - - - { __( 'Templates' ) } - - - { __( 'Patterns' ) } - - - - } + content={ } /> ); } diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index 84820952e1b621..7ecd24719a47bf 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -55,7 +55,7 @@ function createNavState() { }; } -function SidebarContentWrapper( { children } ) { +function SidebarContentWrapper( { children, shouldAnimate } ) { const navState = useContext( SidebarNavigationContext ); const wrapperRef = useRef(); const [ navAnimation, setNavAnimation ] = useState( null ); @@ -66,10 +66,19 @@ function SidebarContentWrapper( { children } ) { setNavAnimation( direction ); }, [ navState ] ); - const wrapperCls = clsx( 'edit-site-sidebar__screen-wrapper', { - 'slide-from-left': navAnimation === 'back', - 'slide-from-right': navAnimation === 'forward', - } ); + const wrapperCls = clsx( + 'edit-site-sidebar__screen-wrapper', + /* + * Some panes do not have sub-panes and therefore + * should not animate when clicked on. + */ + shouldAnimate + ? { + 'slide-from-left': navAnimation === 'back', + 'slide-from-right': navAnimation === 'forward', + } + : {} + ); return (
@@ -78,13 +87,20 @@ function SidebarContentWrapper( { children } ) { ); } -export default function SidebarContent( { routeKey, children } ) { +export default function SidebarContent( { + routeKey, + shouldAnimate, + children, +} ) { const [ navState ] = useState( createNavState ); return (
- + { children }
diff --git a/packages/edit-site/src/components/site-editor-routes/styles-edit.js b/packages/edit-site/src/components/site-editor-routes/styles-edit.js index ff52b957bc3609..e8225a8f526ebd 100644 --- a/packages/edit-site/src/components/site-editor-routes/styles-edit.js +++ b/packages/edit-site/src/components/site-editor-routes/styles-edit.js @@ -3,15 +3,24 @@ */ import Editor from '../editor'; import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-global-styles'; +import GlobalStylesUIWrapper from '../sidebar-global-styles-wrapper'; export const stylesEditRoute = { name: 'styles-edit', match: ( params ) => { - return params.path === '/wp_global_styles' && params.canvas === 'edit'; + return ( + params.path && + params.path.startsWith( '/wp_global_styles' ) && + params.canvas !== 'edit' + ); }, areas: { + content: , sidebar: , preview: , mobile: , }, + widths: { + content: 380, + }, }; diff --git a/packages/edit-site/src/components/site-editor-routes/styles-view.js b/packages/edit-site/src/components/site-editor-routes/styles-view.js index 856a610eb23677..cc9411eb8144c0 100644 --- a/packages/edit-site/src/components/site-editor-routes/styles-view.js +++ b/packages/edit-site/src/components/site-editor-routes/styles-view.js @@ -3,14 +3,24 @@ */ import Editor from '../editor'; import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-global-styles'; +import GlobalStylesUIWrapper from '../sidebar-global-styles-wrapper'; export const stylesViewRoute = { name: 'styles-view', match: ( params ) => { - return params.path === '/wp_global_styles' && params.canvas !== 'edit'; + return ( + params.path && + params.path.startsWith( '/wp_global_styles' ) && + params.canvas !== 'edit' + ); }, areas: { + content: , sidebar: , preview: , + mobile: , + }, + widths: { + content: 380, }, }; diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index e9660323b83734..9918c169ff6ab0 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -24,7 +24,14 @@ import { import { privateApis as editorPrivateApis } from '@wordpress/editor'; import { useSelect } from '@wordpress/data'; import { useResizeObserver } from '@wordpress/compose'; -import { useMemo, useState, memo, useContext } from '@wordpress/element'; +import { + useMemo, + useState, + memo, + useContext, + useRef, + useLayoutEffect, +} from '@wordpress/element'; import { ENTER, SPACE } from '@wordpress/keycodes'; /** @@ -53,6 +60,48 @@ function isObjectEmpty( object ) { return ! object || Object.keys( object ).length === 0; } +/** + * Scrolls to a section within an iframe. + * + * @param {string} anchorId The id of the element to scroll to. + * @param {HTMLIFrameElement} iframe The target iframe. + */ +const scrollToSection = ( anchorId, iframe ) => { + if ( ! iframe || ! iframe?.contentDocument ) { + return; + } + + const element = iframe.contentDocument.getElementById( anchorId ); + if ( element ) { + element.scrollIntoView( { + behavior: 'smooth', + } ); + } +}; + +/** + * Parses a Block Editor navigation path to extract the block name and + * build a style book navigation path. The object can be extended to include a category, + * representing a style book tab/section. + * + * @param {string} path An internal Block Editor navigation path. + * @return {null|{block: string}} An object containing the example to navigate to. + */ +const getStyleBookNavigationFromPath = ( path ) => { + if ( path && typeof path === 'string' ) { + let block = path.includes( '/blocks/' ) + ? decodeURIComponent( path.split( '/blocks/' )[ 1 ] ) + : null; + // Default to theme-colors if the path ends with /colors. + block = path.endsWith( '/colors' ) ? 'theme-colors' : block; + + return { + block, + }; + } + return null; +}; + /** * Retrieves colors, gradients, and duotone filters from Global Styles. * The inclusion of default (Core) palettes is controlled by the relevant @@ -137,6 +186,7 @@ function StyleBook( { onClose, showTabs = true, userConfig = {}, + path = '', } ) { const [ resizeObserver, sizes ] = useResizeObserver(); const [ textColor ] = useGlobalStyle( 'color.text' ); @@ -154,6 +204,7 @@ function StyleBook( { ); const { base: baseConfig } = useContext( GlobalStylesContext ); + const goTo = getStyleBookNavigationFromPath( path ); const mergedConfig = useMemo( () => { if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) { @@ -228,6 +279,7 @@ function StyleBook( { settings={ settings } sizes={ sizes } title={ tab.title } + goTo={ goTo } /> ) ) } @@ -240,6 +292,7 @@ function StyleBook( { onSelect={ onSelect } settings={ settings } sizes={ sizes } + goTo={ goTo } /> ) }
@@ -256,9 +309,11 @@ const StyleBookBody = ( { settings, sizes, title, + goTo, } ) => { const [ isFocused, setIsFocused ] = useState( false ); - + const [ hasIframeLoaded, setHasIframeLoaded ] = useState( false ); + const iframeRef = useRef( null ); // The presence of an `onClick` prop indicates that the Style Book is being used as a button. // In this case, add additional props to the iframe to make it behave like a button. const buttonModeProps = { @@ -287,8 +342,17 @@ const StyleBookBody = ( { readonly: true, }; + const handleLoad = () => setHasIframeLoaded( true ); + useLayoutEffect( () => { + if ( goTo?.block && hasIframeLoaded && iframeRef?.current ) { + scrollToSection( `example-${ goTo?.block }`, iframeRef?.current ); + } + }, [ iframeRef?.current, goTo?.block, scrollToSection, hasIframeLoaded ] ); + return (