diff --git a/.eslintrc.js b/.eslintrc.js index d0c22090b93e87..e5f42eea656b90 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -137,6 +137,11 @@ const restrictedSyntax = [ message: 'Avoid truthy checks on length property rendering, as zero length is rendered verbatim.', }, + { + selector: + 'CallExpression[callee.name=/^(__|_x|_n|_nx)$/] > Literal[value=/^toggle\\b/i]', + message: "Avoid using the verb 'Toggle' in translatable strings", + }, ]; /** `no-restricted-syntax` rules for components. */ diff --git a/.github/workflows/sync-assets-to-plugin-repo.yml b/.github/workflows/sync-assets-to-plugin-repo.yml new file mode 100644 index 00000000000000..c841b3ffc79579 --- /dev/null +++ b/.github/workflows/sync-assets-to-plugin-repo.yml @@ -0,0 +1,48 @@ +name: Sync Gutenberg plugin assets to WordPress.org plugin repo + +on: + push: + branches: + - trunk + paths: + - assets/** + +jobs: + sync-assets: + name: Sync assets to WordPress.org plugin repo + runs-on: ubuntu-latest + environment: wp.org plugin + env: + PLUGIN_REPO_URL: 'https://plugins.svn.wordpress.org/gutenberg' + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + + steps: + - name: Check out Gutenberg assets folder from WP.org plugin repo + run: | + svn checkout "$PLUGIN_REPO_URL/assets" \ + --username "$SVN_USERNAME" --password "$SVN_PASSWORD" + + - name: Delete everything + run: find assets -type f -not -path 'assets/.svn/*' -delete + + - name: Checkout assets from current release + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: | + assets + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + path: git + + - name: Copy files from git checkout to svn working copy + run: cp -R git/assets/* assets + + - name: Commit the updated assets + working-directory: ./assets + run: | + svn st | awk '/^?/ {print $2}' | xargs -r svn add + svn st | awk '/^!/ {print $2}' | xargs -r svn rm + svn commit . \ + -m "Sync assets for commit $GITHUB_SHA" \ + --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" \ + --config-option=servers:global:http-timeout=600 diff --git a/.github/workflows/upload-release-to-plugin-repo.yml b/.github/workflows/upload-release-to-plugin-repo.yml index e866964e69b2d1..4d2b0a66a7e7d6 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 diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 00000000000000..e437ec744d3807 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,7 @@ +## Gutenberg Plugin Assets + +The contents of this directory are synced from the [`assets/` directory in the Gutenberg repository on GitHub](https://github.com/WordPress/gutenberg/tree/trunk/assets) to the [`assets/` directory of the Gutenberg WordPress.org plugin repository](https://plugins.trac.wordpress.org/browser/gutenberg/assets). **Any changes committed directly to the plugin repository on WordPress.org will be overwritten.** + +The sync is performed by a [GitHub Actions workflow](https://github.com/WordPress/gutenberg/actions/workflows/sync-assets-to-plugin-repo.yml) that is triggered whenever a file in this directory is changed. + +Since that workflow requires access to WP.org plugin repository credentials, it needs to be approved manually by a member of the Gutenberg Core team. If you don't have the necessary permissions, please ask someone in [#core-editor](https://wordpress.slack.com/archives/C02QB2JS7). diff --git a/assets/banner-1544x500.jpg b/assets/banner-1544x500.jpg new file mode 100644 index 00000000000000..12e7192dd4285e Binary files /dev/null and b/assets/banner-1544x500.jpg differ diff --git a/assets/banner-772x250.jpg b/assets/banner-772x250.jpg new file mode 100644 index 00000000000000..316f7741071cbe Binary files /dev/null and b/assets/banner-772x250.jpg differ diff --git a/assets/icon-128x128.jpg b/assets/icon-128x128.jpg new file mode 100644 index 00000000000000..051af8504a919b Binary files /dev/null and b/assets/icon-128x128.jpg differ diff --git a/assets/icon-256x256.jpg b/assets/icon-256x256.jpg new file mode 100644 index 00000000000000..b7497f61652b7b Binary files /dev/null and b/assets/icon-256x256.jpg differ diff --git a/backport-changelog/6.8/7129.md b/backport-changelog/6.8/7129.md index 90c9168cdc6f8a..301f1abc45d0d7 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/backport-changelog/6.8/8014.md b/backport-changelog/6.8/8014.md new file mode 100644 index 00000000000000..3ff171d5fb367e --- /dev/null +++ b/backport-changelog/6.8/8014.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/8014 + +* https://github.com/WordPress/gutenberg/pull/66479 diff --git a/backport-changelog/6.8/8015.md b/backport-changelog/6.8/8015.md new file mode 100644 index 00000000000000..214705518a0e72 --- /dev/null +++ b/backport-changelog/6.8/8015.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/8015 + +* https://github.com/WordPress/gutenberg/pull/68058 diff --git a/bin/api-docs/gen-components-docs/markdown/props.mjs b/bin/api-docs/gen-components-docs/markdown/props.mjs index 9d019c4240f008..aaa73041217528 100644 --- a/bin/api-docs/gen-components-docs/markdown/props.mjs +++ b/bin/api-docs/gen-components-docs/markdown/props.mjs @@ -48,4 +48,3 @@ export function generateMarkdownPropsJson( props, { headingLevel = 2 } = {} ) { return [ { [ `h${ headingLevel }` ]: 'Props' }, ...propsJson ]; } - diff --git a/bin/check-licenses.mjs b/bin/check-licenses.mjs index 458590e696a9fd..b453ebd84cd3a7 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/bin/plugin/lib/utils.js b/bin/plugin/lib/utils.js index 4f57269d60c772..f4ef86c96ff081 100644 --- a/bin/plugin/lib/utils.js +++ b/bin/plugin/lib/utils.js @@ -2,7 +2,7 @@ * External dependencies */ // @ts-ignore -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); const fs = require( 'fs' ); const childProcess = require( 'child_process' ); const { v4: uuid } = require( 'uuid' ); @@ -97,14 +97,19 @@ async function askForConfirmation( isDefault = true, abortMessage = 'Aborting.' ) { - const { isReady } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'isReady', + let isReady = false; + try { + isReady = await confirm( { default: isDefault, message, - }, - ] ); + } ); + } catch ( error ) { + if ( error instanceof Error && error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } if ( ! isReady ) { log( formats.error( '\n' + abortMessage ) ); diff --git a/changelog.txt b/changelog.txt index 8e7c1d84d7c7da..665265aef64d46 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ == Changelog == -= 19.9.0-rc.1 = += 19.9.0 = ## Changelog @@ -35,8 +35,8 @@ - Navigation: Enable all non-interactive formats. ([67585](https://github.com/WordPress/gutenberg/pull/67585)) - Query block: Move patterns modal to dropdown on block toolbar. ([66993](https://github.com/WordPress/gutenberg/pull/66993)) - Separator block: Allow divs to be used as separators. ([67530](https://github.com/WordPress/gutenberg/pull/67530)) -- [ New Block ] Add Query Total block for displaying total query results or ranges. ([67629](https://github.com/WordPress/gutenberg/pull/67629)) -- [Block Library]: Update the relationship of `No results` block to `ancestor`. ([48348](https://github.com/WordPress/gutenberg/pull/48348)) +- New Block: Add Query Total block for displaying total query results or ranges. ([67629](https://github.com/WordPress/gutenberg/pull/67629)) +- Block Library: Update the relationship of `No results` block to `ancestor`. ([48348](https://github.com/WordPress/gutenberg/pull/48348)) #### DataViews - Add header to the quick edit when bulk editing. ([67390](https://github.com/WordPress/gutenberg/pull/67390)) @@ -335,6 +335,7 @@ - DataViews build-wp: Don't bundle the date package. ([67612](https://github.com/WordPress/gutenberg/pull/67612)) - Keycodes: Improve tree shaking by annotating exports as pure. ([67615](https://github.com/WordPress/gutenberg/pull/67615)) - Upgrade TypeScript to 5.7 and fix types. ([67461](https://github.com/WordPress/gutenberg/pull/67461)) +- Combine the release steps to ensure that releases are tagged. ([65591](https://github.com/WordPress/gutenberg/pull/65591)) #### Testing - e2e-test-utils-playwright: Increase timeout of site-editor selector. ([66672](https://github.com/WordPress/gutenberg/pull/66672)) @@ -381,7 +382,9 @@ The following PRs were merged by first-time contributors: The following contributors merged PRs in this release: -@aaronrobertshaw @afercia @akasunil @alexflorisca @annezazu @benazeer-ben @ciampo @creador-dev @creativecoder @DAreRodz @dcalhoun @dknauss @draganescu @ellatrix @fabiankaegy @getdave @gigitux @gvgvgvijayan @gziolo @hbhalodia @im3dabasia @imrraaj @jameskoster @jeryj @jorgefilipecosta @jsnajdr @juanfra @louwie17 @Mamaduka @manzoorwanijk @matiasbenedetto @Mayank-Tripathi32 @mcsf @michalczaplinski @miminari @mirka @ntsekouras @oandregal @ockham @prajapatisagar @ramonjd @sabernhardt @SantosGuillamot @sarthaknagoshe2002 @sgomes @shail-mehta @stokesman @subodhr258 @Sukhendu2002 @t-hamano @talldan @tellthemachines @tyxla @viralsampat-multidots @wwdes @yogeshbhutkar @youknowriad +@aaronrobertshaw @afercia @akasunil @alexflorisca @annezazu @benazeer-ben @ciampo @creador-dev @creativecoder @DAreRodz @dcalhoun @dd32 @dknauss @draganescu @ellatrix @fabiankaegy @getdave @gigitux @gvgvgvijayan @gziolo @hbhalodia @im3dabasia @imrraaj @jameskoster @jeryj @jorgefilipecosta @jsnajdr @juanfra @louwie17 @Mamaduka @manzoorwanijk @matiasbenedetto @Mayank-Tripathi32 @mcsf @michalczaplinski @miminari @mirka @ntsekouras @oandregal @ockham @prajapatisagar @ramonjd @sabernhardt @SantosGuillamot @sarthaknagoshe2002 @sgomes @shail-mehta @stokesman @subodhr258 @Sukhendu2002 @t-hamano @talldan @tellthemachines @tyxla @viralsampat-multidots @wwdes @yogeshbhutkar @youknowriad + + = 19.8.0 = diff --git a/docs/README.md b/docs/README.md index 31471a9928b2cf..4fd7d16595e133 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) 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 74942ea3ee93bf..a6427deb863b7e 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. 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 348b95ba88da3c..7accc5d4c2129d 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. diff --git a/docs/getting-started/fundamentals/registration-of-a-block.md b/docs/getting-started/fundamentals/registration-of-a-block.md index 5c80422f6f8574..63a7a9031f72a7 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) 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 b90b4668530797..3c75e1e82668f2 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 diff --git a/docs/private-apis.md b/docs/private-apis.md new file mode 100644 index 00000000000000..14c1a4aa22472b --- /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` diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 437f7be20f7705..bca05d57610934 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -190,7 +190,7 @@ _Parameters_ _Returns_ -- `Object?`: Block attributes. +- `?Object`: Block attributes. ### getBlockCount @@ -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'. diff --git a/docs/reference-guides/data/data-core-blocks.md b/docs/reference-guides/data/data-core-blocks.md index 158b7f92529122..04292135aca51b 100644 --- a/docs/reference-guides/data/data-core-blocks.md +++ b/docs/reference-guides/data/data-core-blocks.md @@ -172,7 +172,7 @@ _Parameters_ _Returns_ -- `Object?`: Block Type. +- `?Object`: Block Type. ### getBlockTypes diff --git a/docs/reference-guides/data/data-core-edit-post.md b/docs/reference-guides/data/data-core-edit-post.md index 06fe5fc30420ae..c316a9266af98a 100644 --- a/docs/reference-guides/data/data-core-edit-post.md +++ b/docs/reference-guides/data/data-core-edit-post.md @@ -65,7 +65,7 @@ Retrieves the template of the currently edited post. _Returns_ -- `Object?`: Post Template. +- `?Object`: Post Template. ### getEditorMode diff --git a/docs/reference-guides/data/data-core-rich-text.md b/docs/reference-guides/data/data-core-rich-text.md index 55220b3ca9c5d9..8c213ee9c69ec4 100644 --- a/docs/reference-guides/data/data-core-rich-text.md +++ b/docs/reference-guides/data/data-core-rich-text.md @@ -46,7 +46,7 @@ _Parameters_ _Returns_ -- `Object?`: Format type. +- `?Object`: Format type. ### getFormatTypeForBareElement diff --git a/docs/tool/manifest.js b/docs/tool/manifest.js index 2004fae84f7ccc..569d78bc5bea8a 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/gutenberg.php b/gutenberg.php index 92f935669fc46e..29cd0f63b40779 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 19.9.0-rc.1 + * Version: 19.9.0 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/compat/wordpress-6.8/blocks.php b/lib/compat/wordpress-6.8/blocks.php index 8e176e58c8d7f5..dc8747c04aec2f 100644 --- a/lib/compat/wordpress-6.8/blocks.php +++ b/lib/compat/wordpress-6.8/blocks.php @@ -170,7 +170,7 @@ function gutenberg_apply_block_hooks_to_post_content( $content ) { * @return WP_REST_Response The response object. */ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { - if ( empty( $response->data['content']['raw'] ) || empty( $response->data['content']['rendered'] ) ) { + if ( empty( $response->data['content']['raw'] ) ) { return $response; } @@ -185,6 +185,8 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { if ( 'wp_navigation' === $post->post_type ) { $wrapper_block_type = 'core/navigation'; + } elseif ( 'wp_block' === $post->post_type ) { + $wrapper_block_type = 'core/block'; } else { $wrapper_block_type = 'core/post-content'; } @@ -206,6 +208,11 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { $response->data['content']['raw'] = $content; + // If the rendered content was previously empty, we leave it like that. + if ( empty( $response->data['content']['rendered'] ) ) { + return $response; + } + // No need to inject hooked blocks twice. $priority = has_filter( 'the_content', 'apply_block_hooks_to_content' ); if ( false !== $priority ) { @@ -224,6 +231,7 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { } add_filter( 'rest_prepare_page', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); add_filter( 'rest_prepare_post', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); +add_filter( 'rest_prepare_wp_block', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); /** * Updates the wp_postmeta with the list of ignored hooked blocks @@ -272,6 +280,8 @@ function gutenberg_update_ignored_hooked_blocks_postmeta( $post ) { if ( 'wp_navigation' === $post->post_type ) { $wrapper_block_type = 'core/navigation'; + } elseif ( 'wp_block' === $post->post_type ) { + $wrapper_block_type = 'core/block'; } else { $wrapper_block_type = 'core/post-content'; } @@ -311,3 +321,4 @@ function gutenberg_update_ignored_hooked_blocks_postmeta( $post ) { } add_filter( 'rest_pre_insert_page', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); add_filter( 'rest_pre_insert_post', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); +add_filter( 'rest_pre_insert_wp_block', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); diff --git a/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php b/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php new file mode 100644 index 00000000000000..f61002f435a760 --- /dev/null +++ b/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php @@ -0,0 +1,205 @@ + 'id=>parent', + 'posts_per_page' => -1, + ) + ); + $query = new WP_Query( $new_args ); + $posts = $query->posts; + $result = self::sort( $posts ); + + self::$post_ids = $result['post_ids']; + self::$levels = $result['levels']; + } + + /** + * Check if the request is eligible for hierarchical sorting. + * + * @param array $request The request data. + * + * @return bool Return true if the request is eligible for hierarchical sorting. + */ + public static function is_eligible( $request ) { + if ( ! isset( $request['orderby_hierarchy'] ) || true !== $request['orderby_hierarchy'] ) { + return false; + } + + return true; + } + + public static function get_ancestor( $post_id ) { + return get_post( $post_id )->post_parent ?? 0; + } + + /** + * Sort posts by hierarchy. + * + * Takes an array of posts and sorts them based on their parent-child relationships. + * It also tracks the level depth of each post in the hierarchy. + * + * Example input: + * ``` + * [ + * ['ID' => 4, 'post_parent' => 2], + * ['ID' => 2, 'post_parent' => 0], + * ['ID' => 3, 'post_parent' => 2], + * ] + * ``` + * + * Example output: + * ``` + * [ + * 'post_ids' => [2, 4, 3], + * 'levels' => [0, 1, 1] + * ] + * ``` + * + * @param array $posts Array of post objects containing ID and post_parent properties. + * + * @return array { + * Sorted post IDs and their hierarchical levels + * + * @type array $post_ids Array of post IDs + * @type array $levels Array of levels for the corresponding post ID in the same index + * } + */ + public static function sort( $posts ) { + /* + * Arrange pages in two arrays: + * + * - $top_level: posts whose parent is 0 + * - $children: post ID as the key and an array of children post IDs as the value. + * Example: $children[10][] contains all sub-pages whose parent is 10. + * + * Additionally, keep track of the levels of each post in $levels. + * Example: $levels[10] = 0 means the post ID is a top-level page. + * + */ + $top_level = array(); + $children = array(); + foreach ( $posts as $post ) { + if ( empty( $post->post_parent ) ) { + $top_level[] = $post->ID; + } else { + $children[ $post->post_parent ][] = $post->ID; + } + } + + $ids = array(); + $levels = array(); + self::add_hierarchical_ids( $ids, $levels, 0, $top_level, $children ); + + // Process remaining children. + if ( ! empty( $children ) ) { + foreach ( $children as $parent_id => $child_ids ) { + $level = 0; + $ancestor = $parent_id; + while ( 0 !== $ancestor ) { + ++$level; + $ancestor = self::get_ancestor( $ancestor ); + } + self::add_hierarchical_ids( $ids, $levels, $level, $child_ids, $children ); + } + } + + return array( + 'post_ids' => $ids, + 'levels' => $levels, + ); + } + + private static function add_hierarchical_ids( &$ids, &$levels, $level, $to_process, $children ) { + foreach ( $to_process as $id ) { + if ( in_array( $id, $ids, true ) ) { + continue; + } + $ids[] = $id; + $levels[ $id ] = $level; + + if ( isset( $children[ $id ] ) ) { + self::add_hierarchical_ids( $ids, $levels, $level + 1, $children[ $id ], $children ); + unset( $children[ $id ] ); + } + } + } + + public static function get_post_ids() { + return self::$post_ids; + } + + public static function get_levels() { + return self::$levels; + } +} + +add_filter( + 'rest_page_collection_params', + function ( $params ) { + $params['orderby_hierarchy'] = array( + 'description' => 'Sort pages by hierarchy.', + 'type' => 'boolean', + 'default' => false, + ); + return $params; + } +); + +add_filter( + 'rest_page_query', + function ( $args, $request ) { + if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) { + return $args; + } + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $hs->run( $args ); + + // Reconfigure the args to display only the ids in the list. + $args['post__in'] = $hs->get_post_ids(); + $args['orderby'] = 'post__in'; + + return $args; + }, + 10, + 2 +); + +add_filter( + 'rest_prepare_page', + function ( $response, $post, $request ) { + if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) { + return $response; + } + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $response->data['level'] = $hs->get_levels()[ $post->ID ]; + + return $response; + }, + 10, + 3 +); diff --git a/lib/compat/wordpress-6.8/post.php b/lib/compat/wordpress-6.8/post.php index 639e33b4e5ca51..be842d89b51519 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'] ) && diff --git a/lib/load.php b/lib/load.php index 26af78f3173c53..371f9c54e5fc4a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -45,6 +45,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.8/block-comments.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-comment-controller-6-8.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-post-types-controller-6-8.php'; + require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php'; require __DIR__ . '/compat/wordpress-6.8/rest-api.php'; // Plugin specific code. diff --git a/package-lock.json b/package-lock.json index 32ff2db4986512..bd901baca24a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "workspaces": [ @@ -25,6 +25,7 @@ "@emotion/jest": "11.7.1", "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", + "@inquirer/prompts": "7.2.0", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", @@ -104,7 +105,6 @@ "filenamify": "4.2.0", "glob": "7.1.2", "husky": "7.0.0", - "inquirer": "7.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.6.2", "jest-jasmine2": "29.6.2", @@ -5265,6 +5265,264 @@ "node": ">=6.9.0" } }, + "node_modules/@inquirer/checkbox": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.3.tgz", + "integrity": "sha512-CEt9B4e8zFOGtc/LYeQx5m8nfqQeG/4oNNv0PUvXGG0mys+wR/WbJ3B4KfSQ4Fcr3AQfpiuFOi3fVvmPfvNbxw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.0.tgz", + "integrity": "sha512-osaBbIMEqVFjTX5exoqPXs6PilWQdjaLhGtMDXMXg/yxkHXNq43GlxGyTA35lK2HpzUgDN+Cjh/2AmqCN0QJpw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.1.tgz", + "integrity": "sha512-rmZVXy9iZvO3ZStEe/ayuuwIJ23LSF13aPMlLMTQARX6lGUBDHGV8UB5i9MRrfy0+mZwt5/9bdy8llszSD3NQA==", + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.0.tgz", + "integrity": "sha512-Z3LeGsD3WlItDqLxTPciZDbGtm0wrz7iJGS/uUxSiQxef33ZrBq7LhsXg30P7xrWz1kZX4iGzxxj5SKZmJ8W+w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.3.tgz", + "integrity": "sha512-MDszqW4HYBpVMmAoy/FA9laLrgo899UAga0itEjsYrBthKieDZNc0e16gdn7N3cQ0DSf/6zsTBZMuDYDQU4ktg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.0.tgz", + "integrity": "sha512-16B8A9hY741yGXzd8UJ9R8su/fuuyO2e+idd7oVLYjP23wKJ6ILRIIHcnXe8/6AoYgwRS2zp4PNsW/u/iZ24yg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.3.tgz", + "integrity": "sha512-HA/W4YV+5deKCehIutfGBzNxWH1nhvUC67O4fC9ufSijn72yrYnRmzvC61dwFvlXIG1fQaYWi+cqNE9PaB9n6Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.3.tgz", + "integrity": "sha512-3qWjk6hS0iabG9xx0U1plwQLDBc/HA/hWzLFFatADpR6XfE62LqPr9GpFXBkLU0KQUaIXZ996bNG+2yUvocH8w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.2.0.tgz", + "integrity": "sha512-ZXYZ5oGVrb+hCzcglPeVerJ5SFwennmDOPfXq1WyeZIrPGySLbl4W6GaSsBFvu3WII36AOK5yB8RMIEEkBjf8w==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.0.3", + "@inquirer/confirm": "^5.1.0", + "@inquirer/editor": "^4.2.0", + "@inquirer/expand": "^4.0.3", + "@inquirer/input": "^4.1.0", + "@inquirer/number": "^3.0.3", + "@inquirer/password": "^4.0.3", + "@inquirer/rawlist": "^4.0.3", + "@inquirer/search": "^3.0.3", + "@inquirer/select": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.3.tgz", + "integrity": "sha512-5MhinSzfmOiZlRoPezfbJdfVCZikZs38ja3IOoWe7H1dxL0l3Z2jAUgbBldeyhhOkELdGvPlBfQaNbeLslib1w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.3.tgz", + "integrity": "sha512-mQTCbdNolTGvGGVCJSI6afDwiSGTV+fMLPEIMDJgIV6L/s3+RYRpxt6t0DYnqMQmemnZ/Zq0vTIRwoHT1RgcTg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/select": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.3.tgz", + "integrity": "sha512-OZfKDtDE8+J54JYAFTUGZwvKNfC7W/gFCjDkcsO7HnTH/wljsZo9y/FJquOxMy++DY0+9l9o/MOZ8s5s1j5wmw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", + "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -11071,6 +11329,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 +11434,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 +16115,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 @@ -19311,9 +19607,13 @@ } }, "node_modules/cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } }, "node_modules/client-zip": { "version": "2.4.5", @@ -24370,9 +24670,10 @@ } }, "node_modules/external-editor": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz", - "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -27465,145 +27766,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/inquirer": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz", - "integrity": "sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^3.0.0", - "cli-cursor": "^3.1.0", - "cli-width": "^2.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.15", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.5.3", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/inquirer/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/inquirer/node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/inquirer/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -41311,6 +41473,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -41389,6 +41552,7 @@ "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, "dependencies": { "tslib": "^1.9.0" }, @@ -41399,7 +41563,8 @@ "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/sade": { "version": "1.8.1", @@ -48617,6 +48782,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", @@ -50653,6 +50830,7 @@ "version": "4.57.0", "license": "GPL-2.0-or-later", "dependencies": { + "@inquirer/prompts": "^7.2.0", "@wordpress/lazy-import": "*", "chalk": "^4.0.0", "change-case": "^4.1.2", @@ -50660,7 +50838,6 @@ "commander": "^9.2.0", "execa": "^4.0.2", "fast-glob": "^3.2.7", - "inquirer": "^7.1.0", "make-dir": "^3.0.0", "mustache": "^4.0.0", "npm-package-arg": "^8.1.5", @@ -51060,6 +51237,7 @@ "@wordpress/icons": "*", "@wordpress/keyboard-shortcuts": "*", "@wordpress/keycodes": "*", + "@wordpress/media-utils": "5.14.0", "@wordpress/notices": "*", "@wordpress/patterns": "*", "@wordpress/plugins": "*", @@ -51218,12 +51396,12 @@ "version": "10.14.0", "license": "GPL-2.0-or-later", "dependencies": { + "@inquirer/prompts": "^7.2.0", "chalk": "^4.0.0", "copy-dir": "^1.3.0", "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", - "inquirer": "^7.1.0", "js-yaml": "^3.13.1", "ora": "^4.0.2", "rimraf": "^5.0.10", @@ -52562,6 +52740,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/package.json b/package.json index 07d725c4a3edcb..792546d28582fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -34,6 +34,7 @@ "@emotion/jest": "11.7.1", "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", + "@inquirer/prompts": "7.2.0", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", @@ -113,7 +114,6 @@ "filenamify": "4.2.0", "glob": "7.1.2", "husky": "7.0.0", - "inquirer": "7.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.6.2", "jest-jasmine2": "29.6.2", diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 13dffce114f59a..8fe2c5f1179dcd 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -713,10 +713,50 @@ Undocumented declaration. ### PlainText +Render an auto-growing textarea allow users to fill any textual content. + _Related_ - +_Usage_ + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { PlainText } from '@wordpress/block-editor'; + +registerBlockType( 'my-plugin/example-block', { + // ... + + attributes: { + content: { + type: 'string', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + setAttributes( { content } ) } + /> + ); + }, +} ); +``` + +_Parameters_ + +- _props_ `Object`: Component props. +- _props.value_ `string`: String value of the textarea. +- _props.onChange_ `Function`: Function called when the text value changes. +- _props.ref_ `[Object]`: The component forwards the `ref` property to the `TextareaAutosize` component. + +_Returns_ + +- `Element`: Plain text component + ### privateApis Private @wordpress/block-editor APIs. 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 bee31ea5872ef5..734226408cb923 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( { <View style={ styles.subtitleContainer }> { isUploadFailed && ( <Icon - icon={ warning } + icon={ cautionFilled } style={ { ...styles.errorIcon, ...uploadFailedStyle, diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/README.md b/packages/block-editor/src/components/block-alignment-matrix-control/README.md index dfb38e15964124..b4267d68fe1fdc 100644 --- a/packages/block-editor/src/components/block-alignment-matrix-control/README.md +++ b/packages/block-editor/src/components/block-alignment-matrix-control/README.md @@ -41,13 +41,36 @@ const controls = ( /> </BlockControls> </> -} +); ``` ### Props -| Name | Type | Default | Description | -| ---------- | ---------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `label` | `string` | `Change matrix alignment` | concise description of tool's functionality. | -| `onChange` | `function` | `noop` | the function to execute upon a user's change of the matrix state | -| `value` | `string` | `center` | describes the content alignment location and can be `top`, `right`, `bottom`, `left`, `topRight`, `bottomRight`, `bottomLeft`, `topLeft` | +### `label` + +- **Type:** `string` +- **Default:** `'Change matrix alignment'` + +Label for the control. + +### `onChange` + +- **Type:** `Function` +- **Default:** `noop` + +Function to execute upon a user's change of the matrix state. + +### `value` + +- **Type:** `string` +- **Default:** `'center'` +- **Options:** `'center'`, `'center center'`, `'center left'`, `'center right'`, `'top center'`, `'top left'`, `'top right'`, `'bottom center'`, `'bottom left'`, `'bottom right'` + +Content alignment location. + +### `isDisabled` + +- **Type:** `boolean` +- **Default:** `false` + +Whether the control should be disabled. \ No newline at end of file diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/index.js b/packages/block-editor/src/components/block-alignment-matrix-control/index.js index cdec41dfc7b978..fef7b424fdc947 100644 --- a/packages/block-editor/src/components/block-alignment-matrix-control/index.js +++ b/packages/block-editor/src/components/block-alignment-matrix-control/index.js @@ -11,6 +11,37 @@ import { const noop = () => {}; +/** + * The alignment matrix control allows users to quickly adjust inner block alignment. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/block-alignment-matrix-control/README.md + * + * @example + * ```jsx + * function Example() { + * return ( + * <BlockControls> + * <BlockAlignmentMatrixControl + * label={ __( 'Change content position' ) } + * value="center" + * onChange={ ( nextPosition ) => + * setAttributes( { contentPosition: nextPosition } ) + * } + * /> + * </BlockControls> + * ); + * } + * ``` + * + * @param {Object} props Component props. + * @param {string} props.label Label for the control. Defaults to 'Change matrix alignment'. + * @param {Function} props.onChange Function to execute upon change of matrix state. + * @param {string} props.value Content alignment location. One of: 'center', 'center center', + * 'center left', 'center right', 'top center', 'top left', + * 'top right', 'bottom center', 'bottom left', 'bottom right'. + * @param {boolean} props.isDisabled Whether the control should be disabled. + * @return {Element} The BlockAlignmentMatrixControl component. + */ function BlockAlignmentMatrixControl( props ) { const { label = __( 'Change matrix alignment' ), diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js b/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js new file mode 100644 index 00000000000000..c2e1d27ea55b9f --- /dev/null +++ b/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BlockAlignmentMatrixControl from '../'; + +const meta = { + title: 'BlockEditor/BlockAlignmentMatrixControl', + component: BlockAlignmentMatrixControl, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'Renders a control for selecting block alignment using a matrix of alignment options.', + }, + }, + }, + argTypes: { + label: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: "'Change matrix alignment'" }, + }, + description: 'Label for the control.', + }, + onChange: { + action: 'onChange', + control: { type: null }, + table: { + type: { summary: 'function' }, + defaultValue: { summary: '() => {}' }, + }, + description: + "Function to execute upon a user's change of the matrix state.", + }, + isDisabled: { + control: 'boolean', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + description: 'Whether the control should be disabled.', + }, + value: { + control: { type: null }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: "'center'" }, + }, + description: 'Content alignment location.', + }, + }, +}; + +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + + return ( + <BlockAlignmentMatrixControl + { ...args } + value={ value } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/block-card/index.js b/packages/block-editor/src/components/block-card/index.js index c8a12a3be5ef6a..988dcfb2216b2a 100644 --- a/packages/block-editor/src/components/block-card/index.js +++ b/packages/block-editor/src/components/block-card/index.js @@ -11,9 +11,10 @@ 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 { __, isRTL } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; /** @@ -21,6 +22,9 @@ import { useSelect, useDispatch } from '@wordpress/data'; */ 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 ) { @@ -66,14 +70,10 @@ function BlockCard( { title, icon, description, blockType, className, name } ) { <BlockIcon icon={ icon } showColors /> <VStack spacing={ 1 }> <h2 className="block-editor-block-card__title"> - { name?.length - ? sprintf( - // translators: 1: Custom block name. 2: Block title. - _x( '%1$s (%2$s)', 'block label' ), - name, - title - ) - : title } + <span className="block-editor-block-card__name"> + { !! name?.length ? name : title } + </span> + { !! name?.length && <Badge>{ title }</Badge> } </h2> { description && ( <Text className="block-editor-block-card__description"> diff --git a/packages/block-editor/src/components/block-card/stories/index.story.js b/packages/block-editor/src/components/block-card/stories/index.story.js new file mode 100644 index 00000000000000..0fe68e2032d394 --- /dev/null +++ b/packages/block-editor/src/components/block-card/stories/index.story.js @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { box, button, cog, paragraph } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import BlockCard from '../'; + +const meta = { + title: 'BlockEditor/BlockCard', + component: BlockCard, + parameters: { + docs: { + description: { + component: + 'The `BlockCard` component allows to display a "card" which contains the title of a block, its icon and its description.', + }, + canvas: { sourceState: 'shown' }, + }, + }, + argTypes: { + title: { + control: 'text', + description: 'The title of the block.', + table: { + type: { summary: 'string' }, + }, + }, + description: { + control: 'text', + description: 'A description of the block functionality.', + table: { + type: { summary: 'string' }, + }, + }, + icon: { + control: 'select', + options: [ 'paragraph', 'cog', 'box', 'button' ], + mapping: { + paragraph, + cog, + box, + button, + }, + description: + 'The icon of the block. This can be any of [WordPress Dashicons](https://developer.wordpress.org/resource/dashicons/), or a custom `svg` element.', + table: { + type: { summary: 'string | object' }, + }, + }, + name: { + control: 'text', + description: 'Optional custom name for the block.', + table: { + type: { summary: 'string' }, + }, + }, + className: { + control: 'text', + description: 'Additional CSS class names.', + table: { + type: { summary: 'string' }, + }, + }, + }, +}; + +export default meta; + +export const Default = { + args: { + title: 'Paragraph', + icon: paragraph, + description: 'This is a paragraph block description.', + name: 'Paragraph Block', + }, +}; diff --git a/packages/block-editor/src/components/block-card/style.scss b/packages/block-editor/src/components/block-card/style.scss index 42cf77aa4b0a84..a5cb675597908b 100644 --- a/packages/block-editor/src/components/block-card/style.scss +++ b/packages/block-editor/src/components/block-card/style.scss @@ -7,15 +7,22 @@ .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; line-height: $default-line-height; margin: 0; - padding: 3px 0; // This makes the title as high as the icon. } } +.block-editor-block-card__name { + padding: 3px 0; // This makes the title as high as the icon. +} + .block-editor-block-card .block-editor-block-icon { flex: 0 0 $button-size-small; margin-left: 0; @@ -27,3 +34,4 @@ .block-editor-block-card.is-synced .block-editor-block-icon { color: var(--wp-block-synced-color); } + diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 2d91108ccb4123..bcf6783a10d1c3 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -12,11 +12,7 @@ import { useDispatch, useRegistry, } from '@wordpress/data'; -import { - useViewportMatch, - useMergeRefs, - useDebounce, -} from '@wordpress/compose'; +import { useMergeRefs, useDebounce } from '@wordpress/compose'; import { createContext, useMemo, @@ -46,7 +42,6 @@ export const IntersectionObserver = createContext(); const pendingBlockVisibilityUpdatesPerRegistry = new WeakMap(); function Root( { className, ...settings } ) { - const isLargeViewport = useViewportMatch( 'medium' ); const { isOutlineMode, isFocusMode, temporarilyEditingAsBlocks } = useSelect( ( select ) => { const { getSettings, getTemporarilyEditingAsBlocks, isTyping } = @@ -105,7 +100,7 @@ function Root( { className, ...settings } ) { ] ), className: clsx( 'is-root-container', className, { 'is-outline-mode': isOutlineMode, - 'is-focus-mode': isFocusMode && isLargeViewport, + 'is-focus-mode': isFocusMode, } ), }, settings diff --git a/packages/block-editor/src/components/block-styles/utils.js b/packages/block-editor/src/components/block-styles/utils.js index 511e78da83da60..e4483ec4e695f8 100644 --- a/packages/block-editor/src/components/block-styles/utils.js +++ b/packages/block-editor/src/components/block-styles/utils.js @@ -10,7 +10,7 @@ import { _x } from '@wordpress/i18n'; * @param {Array} styles Block styles. * @param {string} className Class name * - * @return {Object?} The active style. + * @return {?Object} The active style. */ export function getActiveStyle( styles, className ) { for ( const style of new TokenList( className ).values() ) { @@ -34,7 +34,7 @@ export function getActiveStyle( styles, className ) { * Replaces the active style in the block's className. * * @param {string} className Class name. - * @param {Object?} activeStyle The replaced style. + * @param {?Object} activeStyle The replaced style. * @param {Object} newStyle The replacing style. * * @return {string} The updated className. @@ -83,7 +83,7 @@ export function getRenderedStyles( styles ) { * * @param {Array} styles Block styles. * - * @return {Object?} The default style object, if found. + * @return {?Object} The default style object, if found. */ export function getDefaultStyle( styles ) { return styles?.find( ( style ) => style.isDefault ); diff --git a/packages/block-editor/src/components/block-title/stories/index.story.js b/packages/block-editor/src/components/block-title/stories/index.story.js new file mode 100644 index 00000000000000..dc66fc721e5158 --- /dev/null +++ b/packages/block-editor/src/components/block-title/stories/index.story.js @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { registerCoreBlocks } from '@wordpress/block-library'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { ExperimentalBlockEditorProvider } from '../../provider'; +import BlockTitle from '../'; + +// Register core blocks for the story environment +registerCoreBlocks(); + +// Sample blocks for testing +const blocks = [ createBlock( 'core/paragraph' ) ]; + +const meta = { + title: 'BlockEditor/BlockTitle', + component: BlockTitle, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + "Renders the block's configured title as a string, or empty if the title cannot be determined.", + }, + }, + }, + decorators: [ + ( Story ) => ( + <ExperimentalBlockEditorProvider value={ blocks }> + <Story /> + </ExperimentalBlockEditorProvider> + ), + ], + argTypes: { + clientId: { + control: { type: null }, + description: 'Client ID of block.', + table: { + type: { + summary: 'string', + }, + }, + }, + maximumLength: { + control: { type: 'number' }, + description: + 'The maximum length that the block title string may be before truncated.', + table: { + type: { + summary: 'number', + }, + }, + }, + context: { + control: { type: 'text' }, + description: 'The context to pass to `getBlockLabel`.', + table: { + type: { + summary: 'string', + }, + }, + }, + }, +}; + +export default meta; + +export const Default = { + args: { + clientId: blocks[ 0 ].clientId, + }, +}; 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 17af902bf9baf2..56b8d46b067844 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 + // Indices match then we don't need to render the inserter. + if ( + insertionPointVisible && + blockInsertionPoint?.index === insertionIndex + ) { + return null; + } return ( <BlockPopoverInbetween @@ -73,11 +91,11 @@ function ZoomOutModeInserters() { onClick={ () => { setInserterIsOpened( { rootClientId: sectionRootClientId, - insertionIndex: index + 1, + insertionIndex, tab: 'patterns', category: 'all', } ); - showInsertionPoint( sectionRootClientId, index + 1, { + showInsertionPoint( sectionRootClientId, insertionIndex, { operation: 'insert', } ); } } diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js index 53b15e2fd2cfdd..4cde8c26d75638 100644 --- a/packages/block-editor/src/components/button-block-appender/index.js +++ b/packages/block-editor/src/components/button-block-appender/index.js @@ -7,7 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { Button } from '@wordpress/components'; -import { forwardRef, useRef } from '@wordpress/element'; +import { forwardRef } from '@wordpress/element'; import { _x, sprintf } from '@wordpress/i18n'; import { Icon, plus } from '@wordpress/icons'; import deprecated from '@wordpress/deprecated'; @@ -16,15 +16,11 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import Inserter from '../inserter'; -import { useMergeRefs } from '@wordpress/compose'; function ButtonBlockAppender( { rootClientId, className, onFocus, tabIndex, onSelect }, ref ) { - const inserterButtonRef = useRef(); - - const mergedInserterButtonRef = useMergeRefs( [ inserterButtonRef, ref ] ); return ( <Inserter position="bottom center" @@ -34,7 +30,6 @@ function ButtonBlockAppender( if ( onSelect && typeof onSelect === 'function' ) { onSelect( ...args ); } - inserterButtonRef.current?.focus(); } } renderToggle={ ( { onToggle, @@ -61,7 +56,7 @@ function ButtonBlockAppender( return ( <Button __next40pxDefaultSize - ref={ mergedInserterButtonRef } + ref={ ref } onFocus={ onFocus } tabIndex={ tabIndex } className={ clsx( diff --git a/packages/block-editor/src/components/contrast-checker/index.native.js b/packages/block-editor/src/components/contrast-checker/index.native.js index edd60473fcc36e..c4f19857ccec7e 100644 --- a/packages/block-editor/src/components/contrast-checker/index.native.js +++ b/packages/block-editor/src/components/contrast-checker/index.native.js @@ -13,7 +13,7 @@ import { speak } from '@wordpress/a11y'; import { __ } from '@wordpress/i18n'; import { useEffect } from '@wordpress/element'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; -import { Icon, warning } from '@wordpress/icons'; +import { Icon, cautionFilled } from '@wordpress/icons'; /** * Internal dependencies */ @@ -52,7 +52,7 @@ function ContrastCheckerMessage( { return ( <View style={ styles[ 'block-editor-contrast-checker' ] }> - <Icon style={ iconStyle } icon={ warning } /> + <Icon style={ iconStyle } icon={ cautionFilled } /> <Text style={ msgStyle }>{ msg }</Text> </View> ); 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 e057bdc31a1680..f6160cb90955b5 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 eb269e03ca5abc..6854ee74a0162b 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, @@ -51,7 +40,11 @@ export default function DateFormatPicker( { onChange, } ) { return ( - <fieldset className="block-editor-date-format-picker"> + <VStack + as="fieldset" + spacing={ 4 } + className="block-editor-date-format-picker" + > <VisuallyHidden as="legend">{ __( 'Date format' ) }</VisuallyHidden> <ToggleControl __nextHasNoMarginBottom @@ -68,7 +61,7 @@ export default function DateFormatPicker( { { format && ( <NonDefaultControls format={ format } onChange={ onChange } /> ) } - </fieldset> + </VStack> ); } diff --git a/packages/block-editor/src/components/date-format-picker/stories/index.story.js b/packages/block-editor/src/components/date-format-picker/stories/index.story.js new file mode 100644 index 00000000000000..12d7e071054949 --- /dev/null +++ b/packages/block-editor/src/components/date-format-picker/stories/index.story.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DateFormatPicker from '../'; + +export default { + title: 'BlockEditor/DateFormatPicker', + component: DateFormatPicker, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'The `DateFormatPicker` component enables users to configure their preferred *date format*. This determines how dates are displayed.', + }, + }, + }, + argTypes: { + defaultFormat: { + control: 'text', + description: + 'The date format that will be used if the user selects "Default".', + table: { + type: { summary: 'string' }, + }, + }, + format: { + control: { type: null }, + description: + 'The selected date format. If `null`, _Default_ is selected.', + table: { + type: { summary: 'string | null' }, + }, + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: + 'Called when a selection is made. If `null`, _Default_ is selected.', + table: { + type: { summary: 'function' }, + }, + }, + }, +}; + +export const Default = { + args: { + defaultFormat: 'M j, Y', + }, + render: function Template( { onChange, ...args } ) { + const [ format, setFormat ] = useState(); + return ( + <DateFormatPicker + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setFormat( ...changeArgs ); + } } + format={ format } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/date-format-picker/style.scss b/packages/block-editor/src/components/date-format-picker/style.scss index 748e43bb8db94a..55f844a9ac887b 100644 --- a/packages/block-editor/src/components/date-format-picker/style.scss +++ b/packages/block-editor/src/components/date-format-picker/style.scss @@ -1,5 +1,7 @@ .block-editor-date-format-picker { - margin-bottom: $grid-unit-20; + margin: 0 0 $grid-unit-20; + padding: 0; + border: none; } .block-editor-date-format-picker__custom-format-select-control__custom-option { diff --git a/packages/block-editor/src/components/global-styles/typography-utils.js b/packages/block-editor/src/components/global-styles/typography-utils.js index 4b7c90ae4f222c..2f4d2b4424a6fb 100644 --- a/packages/block-editor/src/components/global-styles/typography-utils.js +++ b/packages/block-editor/src/components/global-styles/typography-utils.js @@ -45,7 +45,7 @@ import { getFontStylesAndWeights } from '../../utils/get-font-styles-and-weights * @param {Preset} preset * @param {Object} settings * @param {boolean|TypographySettings} settings.typography.fluid Whether fluid typography is enabled, and, optionally, fluid font size options. - * @param {Object?} settings.typography.layout Layout options. + * @param {?Object} settings.typography.layout Layout options. * * @return {string|*} A font-size value or the value of preset.size. */ diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index fabc65d143d1aa..7bdc95d222142d 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -624,7 +624,7 @@ function pickStyleKeys( treeToPickFrom ) { // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ key, - structuredClone( style ), + JSON.parse( JSON.stringify( style ) ), ] ); return Object.fromEntries( clonedEntries ); } diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js index 751e940dd166cc..8ec4b24106ebf3 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 ); } diff --git a/packages/block-editor/src/components/inserter/index.js b/packages/block-editor/src/components/inserter/index.js index 1af81d0231a1a8..47304a7d771747 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 9f393a7ce15202..498030a0019dcc 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 ( <div className={ clsx( 'block-editor-inserter__quick-inserter', { @@ -128,10 +106,9 @@ export default function QuickInserter( { rootClientId={ rootClientId } clientId={ clientId } isAppender={ isAppender } - maxBlockPatterns={ maxBlockPatterns } + maxBlockPatterns={ 0 } maxBlockTypes={ SHOWN_BLOCK_TYPES } isDraggable={ false } - prioritizePatterns={ prioritizePatterns } selectBlockOnInsert={ selectBlockOnInsert } isQuick /> diff --git a/packages/block-editor/src/components/plain-text/README.md b/packages/block-editor/src/components/plain-text/README.md index aa15758118afdc..1e0a7888ed1e4d 100644 --- a/packages/block-editor/src/components/plain-text/README.md +++ b/packages/block-editor/src/components/plain-text/README.md @@ -6,11 +6,11 @@ Render an auto-growing textarea allow users to fill any textual content. ### `value: string` -_Required._ String value of the textarea +_Required._ String value of the textarea. ### `onChange( value: string ): Function` -_Required._ Called when the value changes. +_Required._ Function called when the text value changes. You can also pass any extra prop to the textarea rendered by this component. diff --git a/packages/block-editor/src/components/plain-text/index.js b/packages/block-editor/src/components/plain-text/index.js index 4bd6681f4eb079..d28aabebf7a140 100644 --- a/packages/block-editor/src/components/plain-text/index.js +++ b/packages/block-editor/src/components/plain-text/index.js @@ -15,7 +15,41 @@ import { forwardRef } from '@wordpress/element'; import EditableText from '../editable-text'; /** + * Render an auto-growing textarea allow users to fill any textual content. + * * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/plain-text/README.md + * + * @example + * ```jsx + * import { registerBlockType } from '@wordpress/blocks'; + * import { PlainText } from '@wordpress/block-editor'; + * + * registerBlockType( 'my-plugin/example-block', { + * // ... + * + * attributes: { + * content: { + * type: 'string', + * }, + * }, + * + * edit( { className, attributes, setAttributes } ) { + * return ( + * <PlainText + * className={ className } + * value={ attributes.content } + * onChange={ ( content ) => setAttributes( { content } ) } + * /> + * ); + * }, + * } ); + * ```` + * + * @param {Object} props Component props. + * @param {string} props.value String value of the textarea. + * @param {Function} props.onChange Function called when the text value changes. + * @param {Object} [props.ref] The component forwards the `ref` property to the `TextareaAutosize` component. + * @return {Element} Plain text component */ const PlainText = forwardRef( ( { __experimentalVersion, ...props }, ref ) => { if ( __experimentalVersion === 2 ) { diff --git a/packages/block-editor/src/components/plain-text/stories/index.story.js b/packages/block-editor/src/components/plain-text/stories/index.story.js new file mode 100644 index 00000000000000..d1a6253c0870a7 --- /dev/null +++ b/packages/block-editor/src/components/plain-text/stories/index.story.js @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import PlainText from '..'; + +const meta = { + title: 'BlockEditor/PlainText', + component: PlainText, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'PlainText renders an auto-growing textarea that allows users to enter any textual content.', + }, + }, + }, + argTypes: { + value: { + control: { + type: null, + }, + table: { + type: { + summary: 'string', + }, + }, + description: 'String value of the textarea.', + }, + onChange: { + action: 'onChange', + control: { + type: null, + }, + table: { + type: { + summary: 'function', + }, + }, + description: 'Function called when the text value changes.', + }, + className: { + control: 'text', + table: { + type: { + summary: 'string', + }, + }, + description: 'Additional class name for the PlainText.', + }, + }, +}; + +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + return ( + <PlainText + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js index abbb122ae3a0e0..97aa0b95216870 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<File>} $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 = ( <SlotFillProvider passthrough> { ! settings?.isPreviewMode && <KeyboardShortcuts.Register /> } - <BlockRefsProvider>{ children }</BlockRefsProvider> + <BlockRefsProvider>{ props.children }</BlockRefsProvider> </SlotFillProvider> ); + + if ( window.__experimentalMediaProcessing ) { + return ( + <MediaUploadProvider + settings={ mediaUploadSettings } + useSubRegistry={ false } + > + { children } + </MediaUploadProvider> + ); + } + + 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 00000000000000..40390a77e746ef --- /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/components/responsive-block-control/index.js b/packages/block-editor/src/components/responsive-block-control/index.js index 148ba9600f0032..388e7ec543693a 100644 --- a/packages/block-editor/src/components/responsive-block-control/index.js +++ b/packages/block-editor/src/components/responsive-block-control/index.js @@ -57,7 +57,7 @@ function ResponsiveBlockControl( props ) { ); const toggleHelpText = __( - 'Toggle between using the same value for all screen sizes or using a unique value per screen size.' + 'Choose whether to use the same value for all screen sizes or a unique value for each screen size.' ); const defaultControl = renderDefaultControl( 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 3744f3fa012a71..fd97f9b60e6a90 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 ( - <TextAlignmentControl - { ...args } - onChange={ ( ...changeArgs ) => { - 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 ( + <TextAlignmentControl + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/text-decoration-control/README.md b/packages/block-editor/src/components/text-decoration-control/README.md index a606140baa330e..87fb6e89bd5712 100644 --- a/packages/block-editor/src/components/text-decoration-control/README.md +++ b/packages/block-editor/src/components/text-decoration-control/README.md @@ -28,7 +28,6 @@ Then, you can use the component in your block editor UI: ### `value` - **Type:** `String` -- **Default:** `none` - **Options:** `none`, `underline`, `line-through` The current value of the Text Decoration setting. You may only choose from the `Options` listed above. diff --git a/packages/block-editor/src/components/text-decoration-control/stories/index.story.js b/packages/block-editor/src/components/text-decoration-control/stories/index.story.js index 2212b484185cde..d139b30a2bb4b5 100644 --- a/packages/block-editor/src/components/text-decoration-control/stories/index.story.js +++ b/packages/block-editor/src/components/text-decoration-control/stories/index.story.js @@ -8,26 +8,61 @@ import { useState } from '@wordpress/element'; */ import TextDecorationControl from '../'; -export default { +const meta = { title: 'BlockEditor/TextDecorationControl', component: TextDecorationControl, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: 'Control to facilitate text decoration selections.', + }, + }, + }, argTypes: { - onChange: { action: 'onChange' }, + value: { + control: { type: null }, + description: 'Currently selected text decoration.', + table: { + type: { + summary: 'string', + }, + }, + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: 'Handles change in text decoration selection.', + table: { + type: { + summary: 'function', + }, + }, + }, + className: { + control: 'text', + description: 'Additional class name to apply.', + table: { + type: { summary: 'string' }, + }, + }, }, }; -const Template = ( { onChange, ...args } ) => { - const [ value, setValue ] = useState(); - return ( - <TextDecorationControl - { ...args } - onChange={ ( ...changeArgs ) => { - 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 ( + <TextDecorationControl + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; 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 221e5ab74ebb2e..529eb199fb76a0 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; } diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 3755aecbcb9d0b..6268ff31b29890 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -177,6 +177,11 @@ export function BackgroundImagePanel( { }, }; + const defaultControls = getBlockSupport( name, [ + BACKGROUND_SUPPORT_KEY, + 'defaultControls', + ] ); + return ( <StylesBackgroundPanel inheritedValue={ inheritedValue } @@ -185,6 +190,7 @@ export function BackgroundImagePanel( { defaultValues={ BACKGROUND_BLOCK_DEFAULT_VALUES } settings={ updatedSettings } onChange={ onChange } + defaultControls={ defaultControls } value={ style } /> ); diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js index 2dab67d6293328..11e17aba3b30da 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={ - <Item> - <BlockBindingsAttribute - attribute={ attribute } - binding={ binding } - fieldsList={ fieldsList } - /> - </Item> - } > - <BlockBindingsPanelDropdown - fieldsList={ fieldsList } - attribute={ attribute } - binding={ binding } - /> + <Menu.TriggerButton render={ <Item /> }> + <BlockBindingsAttribute + attribute={ attribute } + binding={ binding } + fieldsList={ fieldsList } + /> + </Menu.TriggerButton> + <Menu.Popover gutter={ isMobile ? 8 : 36 }> + <BlockBindingsPanelMenuContent + fieldsList={ fieldsList } + attribute={ attribute } + binding={ binding } + /> + </Menu.Popover> </Menu> </ToolsPanelItem> ); diff --git a/packages/block-editor/src/hooks/gap.js b/packages/block-editor/src/hooks/gap.js index 887325e6409dde..c5c4bbb7130350 100644 --- a/packages/block-editor/src/hooks/gap.js +++ b/packages/block-editor/src/hooks/gap.js @@ -8,7 +8,7 @@ import { getSpacingPresetCssVar } from '../components/spacing-sizes-control/util * The string check is for backwards compatibility before Gutenberg supported * split gap values (row and column) and the value was a string n + unit. * - * @param {string? | Object?} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @param {?string | ?Object} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. * @return {Object|null} A value to pass to the BoxControl component. */ export function getGapBoxControlValueFromStyle( blockGapValue ) { @@ -26,7 +26,7 @@ export function getGapBoxControlValueFromStyle( blockGapValue ) { /** * Returns a CSS value for the `gap` property from a given blockGap style. * - * @param {string? | Object?} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @param {?string | ?Object} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. * @param {?string} defaultValue A default gap value. * @return {string|null} The concatenated gap value (row and column). */ diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 5be2b1b3fd40a8..db2acd01665b60 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -245,7 +245,7 @@ export function omitStyle( style, paths, preserveReference = false ) { let newStyle = style; if ( ! preserveReference ) { - newStyle = structuredClone( style ); + newStyle = JSON.parse( JSON.stringify( style ) ); } if ( ! Array.isArray( paths ) ) { diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index e79833e0a73da7..f085eb2807c6fd 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/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ed9e859f028a98..7f8ddc7a4be31c 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -128,7 +128,7 @@ export function isBlockValid( state, clientId ) { * @param {Object} state Editor state. * @param {string} clientId Block client ID. * - * @return {Object?} Block attributes. + * @return {?Object} Block attributes. */ export function getBlockAttributes( state, clientId ) { const block = state.blocks.byClientId.get( clientId ); @@ -1992,7 +1992,7 @@ const getItemFromVariation = ( state, item ) => ( variation ) => { * Returns the calculated frecency. * * 'frecency' is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * @param {number} time When the last insert occurred as a UNIX epoch * @param {number} count The number of inserts that have occurred. @@ -2080,7 +2080,7 @@ const buildBlockTypeItem = * inserter and handle its selection. * * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * Items are returned ordered descendingly by their 'utility' and 'frecency'. * @@ -2236,7 +2236,7 @@ export const getInserterItems = createRegistrySelector( ( select ) => * transform list and handle its selection. * * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * Items are returned ordered descendingly by their 'frecency'. * @@ -2400,7 +2400,7 @@ export const __experimentalGetAllowedBlocks = createSelector( * @typedef {Object} WPDirectInsertBlock * @property {string} name The type of block. * @property {?Object} attributes Attributes to pass to the newly created block. - * @property {?Array<string>} attributesToCopy Attributes to be copied from adjecent blocks when inserted. + * @property {?Array<string>} attributesToCopy Attributes to be copied from adjacent blocks when inserted. */ export function getDirectInsertBlock( state, rootClientId = null ) { if ( ! rootClientId ) { diff --git a/packages/block-library/src/archives/edit.js b/packages/block-library/src/archives/edit.js index b51bd9a4fe1e6b..d4f25da8507f3e 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 ( <> <InspectorControls> @@ -28,6 +35,7 @@ export default function ArchivesEdit( { attributes, setAttributes } ) { type: 'monthly', } ); } } + dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem label={ __( 'Display as dropdown' ) } 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 4cf28f7063ad31..9cf88d804068af 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`] = ` <Svg height={16} style={{}} - viewBox="-2 -2 24 24" + viewBox="0 0 24 24" width={16} xmlns="http://www.w3.org/2000/svg" > diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index 8beef975fad6f3..e8075115cabda4 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -87,6 +87,26 @@ function render_block_core_block( $attributes ) { add_filter( 'render_block_context', $filter_block_context, 1 ); } + $ignored_hooked_blocks = get_post_meta( $attributes['ref'], '_wp_ignored_hooked_blocks', true ); + if ( ! empty( $ignored_hooked_blocks ) ) { + $ignored_hooked_blocks = json_decode( $ignored_hooked_blocks, true ); + $attributes['metadata'] = array( + 'ignoredHookedBlocks' => $ignored_hooked_blocks, + ); + } + + // Wrap in "Block" block so the Block Hooks algorithm can insert blocks + // that are hooked as first or last child of `core/block`. + $content = get_comment_delimited_block_content( + 'core/block', + $attributes, + $content + ); + // Apply Block Hooks. + $content = apply_block_hooks_to_content( $content, $reusable_block ); + // Remove block wrapper. + $content = remove_serialized_parent_block( $content ); + $content = do_blocks( $content ); unset( $seen_refs[ $attributes['ref'] ] ); diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index 9f2a9048af4c0b..d00e522f5a5d2a 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 @@ -16,13 +17,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 +116,41 @@ 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 } ); - } + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); return ( <ToolsPanel label={ __( 'Settings' ) } - resetAll={ () => { - handleChange( undefined ); - } } + resetAll={ () => setAttributes( { width: undefined } ) } + dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem label={ __( 'Button width' ) } - __nextHasNoMarginBottom isShownByDefault hasValue={ () => !! selectedWidth } - onDeselect={ () => handleChange( undefined ) } + onDeselect={ () => setAttributes( { width: undefined } ) } + __nextHasNoMarginBottom > - <ButtonGroup aria-label={ __( 'Button width' ) }> + <ToggleGroupControl + label={ __( 'Button width' ) } + value={ selectedWidth } + onChange={ ( newWidth ) => + setAttributes( { width: newWidth } ) + } + isBlock + __next40pxDefaultSize + __nextHasNoMarginBottom + > { [ 25, 50, 75, 100 ].map( ( widthValue ) => { return ( - <Button + <ToggleGroupControlOption key={ widthValue } - size="small" - variant={ - widthValue === selectedWidth - ? 'primary' - : undefined - } - onClick={ () => handleChange( widthValue ) } - > - { widthValue }% - </Button> + value={ widthValue } + label={ `${ widthValue }%` } + /> ); } ) } - </ButtonGroup> + </ToggleGroupControl> </ToolsPanelItem> </ToolsPanel> ); diff --git a/packages/block-library/src/column/edit.js b/packages/block-library/src/column/edit.js index a0f3cdcf65393d..92a0b41b44ed52 100644 --- a/packages/block-library/src/column/edit.js +++ b/packages/block-library/src/column/edit.js @@ -18,31 +18,52 @@ 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'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; + function ColumnInspectorControls( { width, setAttributes } ) { const [ availableUnits ] = useSettings( 'spacing.units' ); const units = useCustomUnits( { availableUnits: availableUnits || [ '%', 'px', 'em', 'rem', 'vw' ], } ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); return ( - <PanelBody title={ __( 'Settings' ) }> - <UnitControl + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { width: undefined } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + hasValue={ () => width !== undefined } label={ __( 'Width' ) } - __unstableInputWidth="calc(50% - 8px)" - __next40pxDefaultSize - value={ width || '' } - onChange={ ( nextWidth ) => { - nextWidth = 0 > parseFloat( nextWidth ) ? '0' : nextWidth; - setAttributes( { width: nextWidth } ); - } } - units={ units } - /> - </PanelBody> + onDeselect={ () => setAttributes( { width: undefined } ) } + isShownByDefault + > + <UnitControl + label={ __( 'Width' ) } + __unstableInputWidth="calc(50% - 8px)" + __next40pxDefaultSize + value={ width || '' } + onChange={ ( nextWidth ) => { + nextWidth = + 0 > parseFloat( nextWidth ) ? '0' : nextWidth; + setAttributes( { width: nextWidth } ); + } } + units={ units } + /> + </ToolsPanelItem> + </ToolsPanel> ); } diff --git a/packages/block-library/src/columns/edit.js b/packages/block-library/src/columns/edit.js index f8cf0297302ccd..cad79c356fe031 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 { @@ -39,6 +40,7 @@ import { getRedistributedColumnWidths, toWidthPrecision, } from './utils'; +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; const DEFAULT_BLOCK = { name: 'core/column', @@ -51,19 +53,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; @@ -72,7 +70,7 @@ function ColumnInspectorControls( { ); return { - count: getBlockCount( clientId ), + count: blockOrder.length, canInsertColumnBlock: canInsertBlockType( 'core/column', clientId @@ -148,10 +146,26 @@ function ColumnInspectorControls( { replaceInnerBlocks( clientId, innerBlocks ); } + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + return ( - <PanelBody title={ __( 'Settings' ) }> + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + updateColumns( count, minCount ); + setAttributes( { + isStackedOnMobile: true, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > { canInsertColumnBlock && ( - <> + <ToolsPanelItem + label={ __( 'Columns' ) } + isShownByDefault + hasValue={ () => count } + onDeselect={ () => updateColumns( count, minCount ) } + > <RangeControl __nextHasNoMarginBottom __next40pxDefaultSize @@ -170,19 +184,30 @@ function ColumnInspectorControls( { ) } </Notice> ) } - </> + </ToolsPanelItem> ) } - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanelItem label={ __( 'Stack on mobile' ) } - checked={ isStackedOnMobile } - onChange={ () => + isShownByDefault + hasValue={ () => isStackedOnMobile !== true } + onDeselect={ () => setAttributes( { - isStackedOnMobile: ! isStackedOnMobile, + isStackedOnMobile: true, } ) } - /> - </PanelBody> + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Stack on mobile' ) } + checked={ isStackedOnMobile } + onChange={ () => + setAttributes( { + isStackedOnMobile: ! isStackedOnMobile, + } ) + } + /> + </ToolsPanelItem> + </ToolsPanel> ); } diff --git a/packages/block-library/src/cover/edit.native.js b/packages/block-library/src/cover/edit.native.js index 99324545bf798e..7f73ec85a798e6 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 } > <View style={ styles.uploadFailed }> - <Icon icon={ warning } { ...styles.uploadFailedIcon } /> + <Icon + icon={ cautionFilled } + { ...styles.uploadFailedIcon } + /> </View> </View> ) } diff --git a/packages/block-library/src/details/edit.js b/packages/block-library/src/details/edit.js index 314556ba6d5919..9bebdee1a76706 100644 --- a/packages/block-library/src/details/edit.js +++ b/packages/block-library/src/details/edit.js @@ -9,9 +9,18 @@ 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'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; + const TEMPLATE = [ [ 'core/paragraph', @@ -28,6 +37,7 @@ function DetailsEdit( { attributes, setAttributes, clientId } ) { template: TEMPLATE, __experimentalCaptureToolbars: true, } ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); // Check if either the block or the inner blocks are selected. const hasSelection = useSelect( @@ -46,18 +56,37 @@ function DetailsEdit( { attributes, setAttributes, clientId } ) { return ( <> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + showContent: false, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + isShownByDefault label={ __( 'Open by default' ) } - checked={ showContent } - onChange={ () => + hasValue={ () => showContent } + onDeselect={ () => { setAttributes( { - showContent: ! showContent, - } ) - } - /> - </PanelBody> + showContent: false, + } ); + } } + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Open by default' ) } + checked={ showContent } + onChange={ () => + setAttributes( { + showContent: ! showContent, + } ) + } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <details { ...innerBlocksProps } 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 5ce876137ade00..0c9d88a2074019 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`] = ` <Svg height={24} style={{}} - viewBox="-2 -2 24 24" + viewBox="0 0 24 24" width={24} xmlns="http://www.w3.org/2000/svg" > diff --git a/packages/block-library/src/image/transforms.js b/packages/block-library/src/image/transforms.js index 32824c372efdcb..0119009b2182c4 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/block-library/src/latest-posts/block.json b/packages/block-library/src/latest-posts/block.json index bb8c2d24962f3f..58b1c6da81ca33 100644 --- a/packages/block-library/src/latest-posts/block.json +++ b/packages/block-library/src/latest-posts/block.json @@ -111,6 +111,18 @@ "fontSize": true } }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": true, + "color": true, + "width": true, + "style": true + } + }, "interactivity": { "clientNavigation": true } diff --git a/packages/block-library/src/loginout/edit.js b/packages/block-library/src/loginout/edit.js index b6c2e9cf013041..9af634c87371cf 100644 --- a/packages/block-library/src/loginout/edit.js +++ b/packages/block-library/src/loginout/edit.js @@ -1,38 +1,74 @@ /** * 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'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; export default function LoginOutEdit( { attributes, setAttributes } ) { const { displayLoginAsForm, redirectToCurrent } = attributes; + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); return ( <> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + displayLoginAsForm: false, + redirectToCurrent: true, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem label={ __( 'Display login as form' ) } - checked={ displayLoginAsForm } - onChange={ () => - setAttributes( { - displayLoginAsForm: ! displayLoginAsForm, - } ) + isShownByDefault + hasValue={ () => displayLoginAsForm } + onDeselect={ () => + setAttributes( { displayLoginAsForm: false } ) } - /> - <ToggleControl - __nextHasNoMarginBottom + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Display login as form' ) } + checked={ displayLoginAsForm } + onChange={ () => + setAttributes( { + displayLoginAsForm: ! displayLoginAsForm, + } ) + } + /> + </ToolsPanelItem> + <ToolsPanelItem label={ __( 'Redirect to current URL' ) } - checked={ redirectToCurrent } - onChange={ () => - setAttributes( { - redirectToCurrent: ! redirectToCurrent, - } ) + isShownByDefault + hasValue={ () => ! redirectToCurrent } + onDeselect={ () => + setAttributes( { redirectToCurrent: true } ) } - /> - </PanelBody> + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Redirect to current URL' ) } + checked={ redirectToCurrent } + onChange={ () => + setAttributes( { + redirectToCurrent: ! redirectToCurrent, + } ) + } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...useBlockProps( { diff --git a/packages/block-library/src/more/edit.js b/packages/block-library/src/more/edit.js index bcad7ec1b83662..21e26b47bfb160 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 ( <> <InspectorControls> - <PanelBody> - <ToggleControl - __nextHasNoMarginBottom - label={ __( - 'Hide the excerpt on the full content page' - ) } - checked={ !! noTeaser } - onChange={ toggleHideExcerpt } - help={ getHideExcerptHelp } - /> - </PanelBody> + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + noTeaser: false, + } ); + } } + > + <ToolsPanelItem + label={ __( 'Hide excerpt' ) } + isShownByDefault + hasValue={ () => noTeaser } + onDeselect={ () => + setAttributes( { noTeaser: false } ) + } + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( + 'Hide the excerpt on the full content page' + ) } + checked={ !! noTeaser } + onChange={ toggleHideExcerpt } + help={ getHideExcerptHelp } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...useBlockProps() }> <input diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 39073b848d3ca8..5966739aa61a61 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -9,7 +9,8 @@ import clsx from 'clsx'; import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; import { - PanelBody, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, TextControl, TextareaControl, ToolbarButton, @@ -161,71 +162,110 @@ function getMissingText( type ) { function Controls( { attributes, setAttributes, setIsLabelFieldFocused } ) { const { label, url, description, title, rel } = attributes; return ( - <PanelBody title={ __( 'Settings' ) }> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ label ? stripHTML( label ) : '' } - onChange={ ( labelValue ) => { - setAttributes( { label: labelValue } ); - } } + <ToolsPanel label={ __( 'Settings' ) }> + <ToolsPanelItem + hasValue={ () => !! label } label={ __( 'Text' ) } - autoComplete="off" - onFocus={ () => setIsLabelFieldFocused( true ) } - onBlur={ () => setIsLabelFieldFocused( false ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ url ? safeDecodeURI( url ) : '' } - onChange={ ( urlValue ) => { - updateAttributes( - { url: urlValue }, - setAttributes, - attributes - ); - } } + onDeselect={ () => setAttributes( { label: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Text' ) } + value={ label ? stripHTML( label ) : '' } + onChange={ ( labelValue ) => { + setAttributes( { label: labelValue } ); + } } + autoComplete="off" + onFocus={ () => setIsLabelFieldFocused( true ) } + onBlur={ () => setIsLabelFieldFocused( false ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! url } label={ __( 'Link' ) } - autoComplete="off" - /> - <TextareaControl - __nextHasNoMarginBottom - value={ description || '' } - onChange={ ( descriptionValue ) => { - setAttributes( { description: descriptionValue } ); - } } + onDeselect={ () => setAttributes( { url: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Link' ) } + value={ url ? safeDecodeURI( url ) : '' } + onChange={ ( urlValue ) => { + updateAttributes( + { url: urlValue }, + setAttributes, + attributes + ); + } } + autoComplete="off" + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! description } label={ __( 'Description' ) } - help={ __( - 'The description will be displayed in the menu if the current theme supports it.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ title || '' } - onChange={ ( titleValue ) => { - setAttributes( { title: titleValue } ); - } } + onDeselect={ () => setAttributes( { description: '' } ) } + isShownByDefault + > + <TextareaControl + __nextHasNoMarginBottom + label={ __( 'Description' ) } + value={ description || '' } + onChange={ ( descriptionValue ) => { + setAttributes( { description: descriptionValue } ); + } } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! title } label={ __( 'Title attribute' ) } - autoComplete="off" - help={ __( - 'Additional information to help clarify the purpose of the link.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ rel || '' } - onChange={ ( relValue ) => { - setAttributes( { rel: relValue } ); - } } + onDeselect={ () => setAttributes( { title: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Title attribute' ) } + value={ title || '' } + onChange={ ( titleValue ) => { + setAttributes( { title: titleValue } ); + } } + autoComplete="off" + help={ __( + 'Additional information to help clarify the purpose of the link.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! rel } label={ __( 'Rel attribute' ) } - autoComplete="off" - help={ __( - 'The relationship of the linked URL as space-separated link types.' - ) } - /> - </PanelBody> + onDeselect={ () => setAttributes( { rel: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Rel attribute' ) } + value={ rel || '' } + onChange={ ( relValue ) => { + setAttributes( { rel: relValue } ); + } } + autoComplete="off" + help={ __( + 'The relationship of the linked URL as space-separated link types.' + ) } + /> + </ToolsPanelItem> + </ToolsPanel> ); } diff --git a/packages/block-library/src/navigation-submenu/edit.js b/packages/block-library/src/navigation-submenu/edit.js index c89eadf1cb589e..dbdbd23b13b2f6 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( { </BlockControls> { /* Warning, this duplicated in packages/block-library/src/navigation-link/edit.js */ } <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ label || '' } - onChange={ ( labelValue ) => { - setAttributes( { label: labelValue } ); - } } + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + label: '', + url: '', + description: '', + title: '', + rel: '', + } ); + } } + > + <ToolsPanelItem label={ __( 'Text' ) } - autoComplete="off" - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ url || '' } - onChange={ ( urlValue ) => { - setAttributes( { url: urlValue } ); - } } + isShownByDefault + hasValue={ () => !! label } + onDeselect={ () => setAttributes( { label: '' } ) } + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + value={ label || '' } + onChange={ ( labelValue ) => { + setAttributes( { label: labelValue } ); + } } + label={ __( 'Text' ) } + autoComplete="off" + /> + </ToolsPanelItem> + + <ToolsPanelItem label={ __( 'Link' ) } - autoComplete="off" - /> - <TextareaControl - __nextHasNoMarginBottom - value={ description || '' } - onChange={ ( descriptionValue ) => { - setAttributes( { - description: descriptionValue, - } ); - } } + isShownByDefault + hasValue={ () => !! url } + onDeselect={ () => setAttributes( { url: '' } ) } + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + value={ url || '' } + onChange={ ( urlValue ) => { + setAttributes( { url: urlValue } ); + } } + label={ __( 'Link' ) } + autoComplete="off" + /> + </ToolsPanelItem> + + <ToolsPanelItem label={ __( 'Description' ) } - help={ __( - 'The description will be displayed in the menu if the current theme supports it.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ title || '' } - onChange={ ( titleValue ) => { - setAttributes( { title: titleValue } ); - } } + isShownByDefault + hasValue={ () => !! description } + onDeselect={ () => + setAttributes( { description: '' } ) + } + > + <TextareaControl + __nextHasNoMarginBottom + value={ description || '' } + onChange={ ( descriptionValue ) => { + setAttributes( { + description: descriptionValue, + } ); + } } + label={ __( 'Description' ) } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem label={ __( 'Title attribute' ) } - autoComplete="off" - help={ __( - 'Additional information to help clarify the purpose of the link.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ rel || '' } - onChange={ ( relValue ) => { - setAttributes( { rel: relValue } ); - } } + isShownByDefault + hasValue={ () => !! title } + onDeselect={ () => setAttributes( { title: '' } ) } + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + value={ title || '' } + onChange={ ( titleValue ) => { + setAttributes( { title: titleValue } ); + } } + label={ __( 'Title attribute' ) } + autoComplete="off" + help={ __( + 'Additional information to help clarify the purpose of the link.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem label={ __( 'Rel attribute' ) } - autoComplete="off" - help={ __( - 'The relationship of the linked URL as space-separated link types.' - ) } - /> - </PanelBody> + isShownByDefault + hasValue={ () => !! rel } + onDeselect={ () => setAttributes( { rel: '' } ) } + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + value={ rel || '' } + onChange={ ( relValue ) => { + setAttributes( { rel: relValue } ); + } } + label={ __( 'Rel attribute' ) } + autoComplete="off" + help={ __( + 'The relationship of the linked URL as space-separated link types.' + ) } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...blockProps }> { /* eslint-disable jsx-a11y/anchor-is-valid */ } 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 dceabf063b26e8..0efb597ff85324 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 { diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js index 31e400b8676717..ef927ecceccf2d 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'; @@ -37,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. @@ -123,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', @@ -320,38 +323,62 @@ export default function PageListEdit( { return ( <> <InspectorControls> - { pagesTree.length > 0 && ( - <PanelBody> - <ComboboxControl - __nextHasNoMarginBottom - __next40pxDefaultSize - className="editor-page-attributes__parent" - label={ __( 'Parent' ) } - value={ parentPageID } - options={ pagesTree } - onChange={ ( value ) => - setAttributes( { parentPageID: value ?? 0 } ) + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { parentPageID: 0 } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + { pagesTree.length > 0 && ( + <ToolsPanelItem + label={ __( 'Parent Page' ) } + hasValue={ () => parentPageID !== 0 } + onDeselect={ () => + setAttributes( { parentPageID: 0 } ) } - help={ __( - 'Choose a page to show only its subpages.' - ) } - /> - </PanelBody> - ) } - { allowConvertToLinks && ( - <PanelBody title={ __( 'Edit this menu' ) }> - <p>{ convertDescription }</p> - <Button - __next40pxDefaultSize - variant="primary" - accessibleWhenDisabled - disabled={ ! hasResolvedPages } - onClick={ convertToNavigationLinks } + isShownByDefault > - { __( 'Edit' ) } - </Button> - </PanelBody> - ) } + <ComboboxControl + __nextHasNoMarginBottom + __next40pxDefaultSize + className="editor-page-attributes__parent" + label={ __( 'Parent' ) } + value={ parentPageID } + options={ pagesTree } + onChange={ ( value ) => + setAttributes( { + parentPageID: value ?? 0, + } ) + } + help={ __( + 'Choose a page to show only its subpages.' + ) } + /> + </ToolsPanelItem> + ) } + + { allowConvertToLinks && ( + <ToolsPanelItem + label={ __( 'Edit Menu' ) } + isShownByDefault + hasValue={ () => false } + > + <div> + <p>{ convertDescription }</p> + <Button + __next40pxDefaultSize + variant="primary" + accessibleWhenDisabled + disabled={ ! hasResolvedPages } + onClick={ convertToNavigationLinks } + > + { __( 'Edit' ) } + </Button> + </div> + </ToolsPanelItem> + ) } + </ToolsPanel> </InspectorControls> { allowConvertToLinks && ( <> diff --git a/packages/block-library/src/post-author-name/edit.js b/packages/block-library/src/post-author-name/edit.js index b4afb9a9799498..2b4bb0709356b0 100644 --- a/packages/block-library/src/post-author-name/edit.js +++ b/packages/block-library/src/post-author-name/edit.js @@ -13,7 +13,7 @@ import { useBlockProps, } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; import { PanelBody, ToggleControl } from '@wordpress/components'; @@ -22,9 +22,10 @@ function PostAuthorNameEdit( { attributes: { textAlign, isLink, linkTarget }, setAttributes, } ) { - const { authorName } = useSelect( + const { authorName, supportsAuthor } = useSelect( ( select ) => { - const { getEditedEntityRecord, getUser } = select( coreStore ); + const { getEditedEntityRecord, getUser, getPostType } = + select( coreStore ); const _authorId = getEditedEntityRecord( 'postType', postType, @@ -33,6 +34,8 @@ function PostAuthorNameEdit( { return { authorName: _authorId ? getUser( _authorId ) : null, + supportsAuthor: + getPostType( postType )?.supports?.author ?? false, }; }, [ postType, postId ] @@ -90,7 +93,17 @@ function PostAuthorNameEdit( { ) } </PanelBody> </InspectorControls> - <div { ...blockProps }> { displayAuthor } </div> + <div { ...blockProps }> + { supportsAuthor + ? displayAuthor + : sprintf( + // translators: %s: Name of the post type e.g: "post". + __( + 'This post type (%s) does not support the author.' + ), + postType + ) } + </div> </> ); } diff --git a/packages/block-library/src/post-author-name/index.php b/packages/block-library/src/post-author-name/index.php index effc83962a3547..243d78ca70129e 100644 --- a/packages/block-library/src/post-author-name/index.php +++ b/packages/block-library/src/post-author-name/index.php @@ -26,6 +26,10 @@ function render_block_core_post_author_name( $attributes, $content, $block ) { return ''; } + if ( ! post_type_supports( $block->context['postType'], 'author' ) ) { + return ''; + } + $author_name = get_the_author_meta( 'display_name', $author_id ); if ( isset( $attributes['isLink'] ) && $attributes['isLink'] ) { $author_name = sprintf( '<a href="%1$s" target="%2$s" class="wp-block-post-author-name__link">%3$s</a>', get_author_posts_url( $author_id ), esc_attr( $attributes['linkTarget'] ), $author_name ); diff --git a/packages/block-library/src/post-author/edit.js b/packages/block-library/src/post-author/edit.js index 6186b0d052e8aa..dd2b3aa617548d 100644 --- a/packages/block-library/src/post-author/edit.js +++ b/packages/block-library/src/post-author/edit.js @@ -21,7 +21,7 @@ import { __experimentalVStack as VStack, } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; const minimumUsersForCombobox = 25; @@ -38,9 +38,9 @@ function PostAuthorEdit( { setAttributes, } ) { const isDescendentOfQueryLoop = Number.isFinite( queryId ); - const { authorId, authorDetails, authors } = useSelect( + const { authorId, authorDetails, authors, supportsAuthor } = useSelect( ( select ) => { - const { getEditedEntityRecord, getUser, getUsers } = + const { getEditedEntityRecord, getUser, getUsers, getPostType } = select( coreStore ); const _authorId = getEditedEntityRecord( 'postType', @@ -52,6 +52,8 @@ function PostAuthorEdit( { authorId: _authorId, authorDetails: _authorId ? getUser( _authorId ) : null, authors: getUsers( AUTHORS_QUERY ), + supportsAuthor: + getPostType( postType )?.supports?.author ?? false, }; }, [ postType, postId ] @@ -97,6 +99,18 @@ function PostAuthorEdit( { const showAuthorControl = !! postId && ! isDescendentOfQueryLoop && authorOptions.length > 0; + if ( ! supportsAuthor ) { + return ( + <div { ...blockProps }> + { sprintf( + // translators: %s: Name of the post type e.g: "post". + __( 'This post type (%s) does not support the author.' ), + postType + ) } + </div> + ); + } + return ( <> <InspectorControls> diff --git a/packages/block-library/src/post-author/index.php b/packages/block-library/src/post-author/index.php index faf894d997d732..2d01de508b94af 100644 --- a/packages/block-library/src/post-author/index.php +++ b/packages/block-library/src/post-author/index.php @@ -26,6 +26,10 @@ function render_block_core_post_author( $attributes, $content, $block ) { return ''; } + if ( ! post_type_supports( $block->context['postType'], 'author' ) ) { + return ''; + } + $avatar = ! empty( $attributes['avatarSize'] ) ? get_avatar( $author_id, $attributes['avatarSize'] diff --git a/packages/block-library/src/post-date/edit.js b/packages/block-library/src/post-date/edit.js index 5057466c6af453..6ac39c0f14798d 100644 --- a/packages/block-library/src/post-date/edit.js +++ b/packages/block-library/src/post-date/edit.js @@ -26,7 +26,8 @@ import { ToolbarGroup, ToolbarButton, ToggleControl, - PanelBody, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { __, _x, sprintf } from '@wordpress/i18n'; import { edit } from '@wordpress/icons'; @@ -160,16 +161,36 @@ export default function PostDateEdit( { </BlockControls> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <DateFormatPicker - format={ format } - defaultFormat={ siteFormat } - onChange={ ( nextFormat ) => - setAttributes( { format: nextFormat } ) + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + format: undefined, + isLink: false, + displayType: 'date', + } ); + } } + > + <ToolsPanelItem + hasValue={ () => + format !== undefined && format !== siteFormat + } + label={ __( 'Date Format' ) } + onDeselect={ () => + setAttributes( { format: undefined } ) } - /> - <ToggleControl - __nextHasNoMarginBottom + isShownByDefault + > + <DateFormatPicker + format={ format } + defaultFormat={ siteFormat } + onChange={ ( nextFormat ) => + setAttributes( { format: nextFormat } ) + } + /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => isLink !== false } label={ postType?.labels.singular_name ? sprintf( @@ -179,23 +200,49 @@ export default function PostDateEdit( { ) : __( 'Link to post' ) } - onChange={ () => setAttributes( { isLink: ! isLink } ) } - checked={ isLink } - /> - <ToggleControl - __nextHasNoMarginBottom + onDeselect={ () => setAttributes( { isLink: false } ) } + isShownByDefault + > + <ToggleControl + __nextHasNoMarginBottom + label={ + postType?.labels.singular_name + ? sprintf( + // translators: %s: Name of the post type e.g: "post". + __( 'Link to %s' ), + postType.labels.singular_name.toLowerCase() + ) + : __( 'Link to post' ) + } + onChange={ () => + setAttributes( { isLink: ! isLink } ) + } + checked={ isLink } + /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => displayType !== 'date' } label={ __( 'Display last modified date' ) } - onChange={ ( value ) => - setAttributes( { - displayType: value ? 'modified' : 'date', - } ) + onDeselect={ () => + setAttributes( { displayType: 'date' } ) } - checked={ displayType === 'modified' } - help={ __( - 'Only shows if the post has been modified' - ) } - /> - </PanelBody> + isShownByDefault + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Display last modified date' ) } + onChange={ ( value ) => + setAttributes( { + displayType: value ? 'modified' : 'date', + } ) + } + checked={ displayType === 'modified' } + help={ __( + 'Only shows if the post has been modified' + ) } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...blockProps }>{ postDate }</div> diff --git a/packages/block-library/src/post-excerpt/edit.js b/packages/block-library/src/post-excerpt/edit.js index 05aaf543b59196..bc94c2599e60ec 100644 --- a/packages/block-library/src/post-excerpt/edit.js +++ b/packages/block-library/src/post-excerpt/edit.js @@ -16,14 +16,22 @@ 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'; /** * Internal dependencies */ -import { useCanEditEntity } from '../utils/hooks'; +import { + useCanEditEntity, + useToolsPanelDropdownMenuProps, +} from '../utils/hooks'; const ELLIPSIS = '…'; @@ -41,6 +49,8 @@ export default function PostExcerptEditor( { { rendered: renderedExcerpt, protected: isProtected } = {}, ] = useEntityProp( 'postType', postType, 'excerpt', postId ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + /** * Check if the post type supports excerpts. * Add an exception and return early for the "page" post type, @@ -219,29 +229,56 @@ export default function PostExcerptEditor( { /> </BlockControls> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + showMoreOnNewLine: true, + excerptLength: 55, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + hasValue={ () => showMoreOnNewLine !== true } label={ __( 'Show link on new line' ) } - checked={ showMoreOnNewLine } - onChange={ ( newShowMoreOnNewLine ) => - setAttributes( { - showMoreOnNewLine: newShowMoreOnNewLine, - } ) + onDeselect={ () => + setAttributes( { showMoreOnNewLine: true } ) } - /> - <RangeControl - __next40pxDefaultSize - __nextHasNoMarginBottom + isShownByDefault + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Show link on new line' ) } + checked={ showMoreOnNewLine } + onChange={ ( newShowMoreOnNewLine ) => + setAttributes( { + showMoreOnNewLine: newShowMoreOnNewLine, + } ) + } + /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => excerptLength !== 55 } label={ __( 'Max number of words' ) } - value={ excerptLength } - onChange={ ( value ) => { - setAttributes( { excerptLength: value } ); - } } - min="10" - max="100" - /> - </PanelBody> + onDeselect={ () => + setAttributes( { excerptLength: 55 } ) + } + isShownByDefault + > + <RangeControl + __next40pxDefaultSize + __nextHasNoMarginBottom + label={ __( 'Max number of words' ) } + value={ excerptLength } + onChange={ ( value ) => { + setAttributes( { excerptLength: value } ); + } } + min="10" + max="100" + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...blockProps }> { excerptContent } diff --git a/packages/block-library/src/post-featured-image/edit.js b/packages/block-library/src/post-featured-image/edit.js index 95441a5a55cfd0..05888c41fecf23 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' && ( <> <InspectorControls group="color"> @@ -201,9 +205,18 @@ export default function PostFeaturedImageEdit( { /> </InspectorControls> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + isLink: false, + linkTarget: '_self', + rel: '', + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem label={ postType?.labels.singular_name ? sprintf( @@ -213,11 +226,42 @@ export default function PostFeaturedImageEdit( { ) : __( 'Link to post' ) } - onChange={ () => setAttributes( { isLink: ! isLink } ) } - checked={ isLink } - /> + isShownByDefault + hasValue={ () => !! isLink } + onDeselect={ () => + setAttributes( { + isLink: false, + } ) + } + > + <ToggleControl + __nextHasNoMarginBottom + label={ + postType?.labels.singular_name + ? sprintf( + // translators: %s: Name of the post type e.g: "post". + __( 'Link to %s' ), + postType.labels.singular_name + ) + : __( 'Link to post' ) + } + onChange={ () => + setAttributes( { isLink: ! isLink } ) + } + checked={ isLink } + /> + </ToolsPanelItem> { isLink && ( - <> + <ToolsPanelItem + label={ __( 'Open in new tab' ) } + isShownByDefault + hasValue={ () => '_self' !== linkTarget } + onDeselect={ () => + setAttributes( { + linkTarget: '_self', + } ) + } + > <ToggleControl __nextHasNoMarginBottom label={ __( 'Open in new tab' ) } @@ -228,6 +272,19 @@ export default function PostFeaturedImageEdit( { } checked={ linkTarget === '_blank' } /> + </ToolsPanelItem> + ) } + { isLink && ( + <ToolsPanelItem + label={ __( 'Link rel' ) } + isShownByDefault + hasValue={ () => !! rel } + onDeselect={ () => + setAttributes( { + rel: '', + } ) + } + > <TextControl __next40pxDefaultSize __nextHasNoMarginBottom @@ -237,9 +294,9 @@ export default function PostFeaturedImageEdit( { setAttributes( { rel: newRel } ) } /> - </> + </ToolsPanelItem> ) } - </PanelBody> + </ToolsPanel> </InspectorControls> </> ); diff --git a/packages/block-library/src/query-pagination-numbers/edit.js b/packages/block-library/src/query-pagination-numbers/edit.js index b8d8c160cc874d..cf2f92f41791ff 100644 --- a/packages/block-library/src/query-pagination-numbers/edit.js +++ b/packages/block-library/src/query-pagination-numbers/edit.js @@ -3,7 +3,16 @@ */ 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'; + +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; const createPaginationItem = ( content, Tag = 'a', extraClass = '' ) => ( <Tag key={ content } className={ `page-numbers ${ extraClass }` }> @@ -46,28 +55,41 @@ export default function QueryPaginationNumbersEdit( { const paginationNumbers = previewPaginationNumbers( parseInt( midSize, 10 ) ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + return ( <> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <RangeControl - __next40pxDefaultSize - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => setAttributes( { midSize: 2 } ) } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem label={ __( 'Number of links' ) } - help={ __( - 'Specify how many links can appear before and after the current page number. Links to the first, current and last page are always visible.' - ) } - value={ midSize } - onChange={ ( value ) => { - setAttributes( { - midSize: parseInt( value, 10 ), - } ); - } } - min={ 0 } - max={ 5 } - withInputField={ false } - /> - </PanelBody> + hasValue={ () => midSize !== undefined } + onDeselect={ () => setAttributes( { midSize: 2 } ) } + isShownByDefault + > + <RangeControl + __next40pxDefaultSize + __nextHasNoMarginBottom + label={ __( 'Number of links' ) } + help={ __( + 'Specify how many links can appear before and after the current page number. Links to the first, current and last page are always visible.' + ) } + value={ midSize } + onChange={ ( value ) => { + setAttributes( { + midSize: parseInt( value, 10 ), + } ); + } } + min={ 0 } + max={ 5 } + withInputField={ false } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <div { ...useBlockProps() }>{ paginationNumbers }</div> </> diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 1592f0a10cbff5..20b59109874d9e 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -19,14 +19,14 @@ function render_block_core_query_pagination_previous( $attributes, $content, $block ) { $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - - $wrapper_attributes = get_block_wrapper_attributes(); - $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; - $default_label = __( 'Previous Page' ); - $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; - $label = $show_label ? $label_text : ''; - $pagination_arrow = get_query_pagination_arrow( $block, false ); + $wrapper_attributes = get_block_wrapper_attributes(); + $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; + $default_label = __( 'Previous Page' ); + $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label = $show_label ? $label_text : ''; + $pagination_arrow = get_query_pagination_arrow( $block, false ); if ( ! $label ) { $wrapper_attributes .= ' aria-label="' . $label_text . '"'; } @@ -44,13 +44,20 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl add_filter( 'previous_posts_link_attributes', $filter_link_attributes ); $content = get_previous_posts_link( $label ); remove_filter( 'previous_posts_link_attributes', $filter_link_attributes ); - } elseif ( 1 !== $page ) { - $content = sprintf( - '<a href="%1$s" %2$s>%3$s</a>', - esc_url( add_query_arg( $page_key, $page - 1 ) ), - $wrapper_attributes, - $label - ); + } else { + $block_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + $block_max_pages = $block_query->max_num_pages; + $total = ! $max_page || $max_page > $block_max_pages ? $block_max_pages : $max_page; + wp_reset_postdata(); + + if ( 1 < $page && $page <= $total ) { + $content = sprintf( + '<a href="%1$s" %2$s>%3$s</a>', + esc_url( add_query_arg( $page_key, $page - 1 ) ), + $wrapper_attributes, + $label + ); + } } if ( $enhanced_pagination && isset( $content ) ) { diff --git a/packages/block-library/src/query-pagination/edit.js b/packages/block-library/src/query-pagination/edit.js index e051c2e67e7e5a..8ca0705058be28 100644 --- a/packages/block-library/src/query-pagination/edit.js +++ b/packages/block-library/src/query-pagination/edit.js @@ -8,8 +8,11 @@ import { useInnerBlocksProps, store as blockEditorStore, } from '@wordpress/block-editor'; -import { useSelect } from '@wordpress/data'; -import { PanelBody } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +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' ], @@ -46,36 +50,74 @@ 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 ] ); + }, [ + paginationArrow, + setAttributes, + showLabel, + __unstableMarkNextChangeAsNotPersistent, + ] ); + return ( <> { hasNextPreviousBlocks && ( <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <QueryPaginationArrowControls - value={ paginationArrow } - onChange={ ( value ) => { - setAttributes( { paginationArrow: value } ); - } } - /> - { paginationArrow !== 'none' && ( - <QueryPaginationLabelControl - value={ showLabel } + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + paginationArrow: 'none', + showLabel: true, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + hasValue={ () => paginationArrow !== 'none' } + label={ __( 'Pagination arrow' ) } + onDeselect={ () => + setAttributes( { paginationArrow: 'none' } ) + } + isShownByDefault + > + <QueryPaginationArrowControls + value={ paginationArrow } onChange={ ( value ) => { - setAttributes( { showLabel: value } ); + setAttributes( { paginationArrow: value } ); } } /> + </ToolsPanelItem> + { paginationArrow !== 'none' && ( + <ToolsPanelItem + hasValue={ () => ! showLabel } + label={ __( 'Show text' ) } + onDeselect={ () => + setAttributes( { showLabel: true } ) + } + isShownByDefault + > + <QueryPaginationLabelControl + value={ showLabel } + onChange={ ( value ) => { + setAttributes( { showLabel: value } ); + } } + /> + </ToolsPanelItem> ) } - </PanelBody> + </ToolsPanel> </InspectorControls> ) } <nav { ...innerBlocksProps } /> diff --git a/packages/block-library/src/query-pagination/query-pagination-label-control.js b/packages/block-library/src/query-pagination/query-pagination-label-control.js index 9ff80a663adeb5..16766c19bef086 100644 --- a/packages/block-library/src/query-pagination/query-pagination-label-control.js +++ b/packages/block-library/src/query-pagination/query-pagination-label-control.js @@ -9,9 +9,7 @@ export function QueryPaginationLabelControl( { value, onChange } ) { <ToggleControl __nextHasNoMarginBottom label={ __( 'Show label text' ) } - help={ __( - 'Toggle off to hide the label text, e.g. "Next Page".' - ) } + help={ __( 'Make label text visible, e.g. "Next Page".' ) } onChange={ onChange } checked={ value === true } /> diff --git a/packages/block-library/src/site-title/edit.js b/packages/block-library/src/site-title/edit.js index 82e3c1d7f7bb40..44b29173e06b03 100644 --- a/packages/block-library/src/site-title/edit.js +++ b/packages/block-library/src/site-title/edit.js @@ -17,10 +17,19 @@ 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'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; + export default function SiteTitleEdit( { attributes, setAttributes, @@ -43,6 +52,7 @@ export default function SiteTitleEdit( { }; }, [] ); const { editEntityRecord } = useDispatch( coreStore ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); function setTitle( newTitle ) { editEntityRecord( 'root', 'site', undefined, { @@ -109,26 +119,53 @@ export default function SiteTitleEdit( { /> </BlockControls> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + isLink: false, + linkTarget: '_self', + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + hasValue={ () => isLink !== false } label={ __( 'Make title link to home' ) } - onChange={ () => setAttributes( { isLink: ! isLink } ) } - checked={ isLink } - /> - { isLink && ( + onDeselect={ () => setAttributes( { isLink: false } ) } + isShownByDefault + > <ToggleControl __nextHasNoMarginBottom - label={ __( 'Open in new tab' ) } - onChange={ ( value ) => - setAttributes( { - linkTarget: value ? '_blank' : '_self', - } ) + label={ __( 'Make title link to home' ) } + onChange={ () => + setAttributes( { isLink: ! isLink } ) } - checked={ linkTarget === '_blank' } + checked={ isLink } /> + </ToolsPanelItem> + { isLink && ( + <ToolsPanelItem + hasValue={ () => linkTarget !== '_self' } + label={ __( 'Open in new tab' ) } + onDeselect={ () => + setAttributes( { linkTarget: '_self' } ) + } + isShownByDefault + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Open in new tab' ) } + onChange={ ( value ) => + setAttributes( { + linkTarget: value ? '_blank' : '_self', + } ) + } + checked={ linkTarget === '_blank' } + /> + </ToolsPanelItem> ) } - </PanelBody> + </ToolsPanel> </InspectorControls> { siteTitleContent } </> diff --git a/packages/block-library/src/social-link/edit.js b/packages/block-library/src/social-link/edit.js index 91f1e4170b33dd..4cd24505fd552a 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'; @@ -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, @@ -195,8 +197,21 @@ const SocialLinkEdit = ( { </BlockControls> ) } <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <PanelRow> + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { label: undefined } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + isShownByDefault + label={ __( 'Text' ) } + hasValue={ () => !! label } + onDeselect={ () => { + setAttributes( { label: undefined } ); + } } + > <TextControl __next40pxDefaultSize __nextHasNoMarginBottom @@ -210,8 +225,8 @@ const SocialLinkEdit = ( { } placeholder={ socialLinkName } /> - </PanelRow> - </PanelBody> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> <InspectorControls group="advanced"> <TextControl diff --git a/packages/block-library/src/social-links/edit.js b/packages/block-library/src/social-links/edit.js index 068b34a3a70a4e..72fd265d629fb7 100644 --- a/packages/block-library/src/social-links/edit.js +++ b/packages/block-library/src/social-links/edit.js @@ -22,14 +22,20 @@ 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'; import { useSelect } from '@wordpress/data'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; + const sizeOptions = [ { name: __( 'Small' ), value: 'has-small-icon-size' }, { name: __( 'Normal' ), value: 'has-normal-icon-size' }, @@ -68,6 +74,8 @@ export function SocialLinksEdit( props ) { const logosOnly = attributes.className?.includes( 'is-style-logos-only' ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + // Remove icon background color when logos only style is selected or // restore it when any other style is selected. const backgroundBackupRef = useRef( {} ); @@ -198,24 +206,53 @@ export function SocialLinksEdit( props ) { </ToolbarDropdownMenu> </BlockControls> <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <ToggleControl - __nextHasNoMarginBottom + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + openInNewTab: false, + showLabels: false, + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + isShownByDefault label={ __( 'Open links in new tab' ) } - checked={ openInNewTab } - onChange={ () => - setAttributes( { openInNewTab: ! openInNewTab } ) + hasValue={ () => !! openInNewTab } + onDeselect={ () => + setAttributes( { openInNewTab: false } ) } - /> - <ToggleControl - __nextHasNoMarginBottom + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Open links in new tab' ) } + checked={ openInNewTab } + onChange={ () => + setAttributes( { + openInNewTab: ! openInNewTab, + } ) + } + /> + </ToolsPanelItem> + <ToolsPanelItem + isShownByDefault label={ __( 'Show text' ) } - checked={ showLabels } - onChange={ () => - setAttributes( { showLabels: ! showLabels } ) + hasValue={ () => !! showLabels } + onDeselect={ () => + setAttributes( { showLabels: false } ) } - /> - </PanelBody> + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Show text' ) } + checked={ showLabels } + onChange={ () => + setAttributes( { showLabels: ! showLabels } ) + } + /> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> { colorGradientSettings.hasColorsOrGradients && ( <InspectorControls group="color"> diff --git a/packages/block-library/src/spacer/controls.js b/packages/block-library/src/spacer/controls.js index 1e899e15aff0de..fde06d3ee8c339 100644 --- a/packages/block-library/src/spacer/controls.js +++ b/packages/block-library/src/spacer/controls.js @@ -10,10 +10,11 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { - PanelBody, __experimentalUseCustomUnits as useCustomUnits, __experimentalUnitControl as UnitControl, __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { View } from '@wordpress/primitives'; @@ -94,28 +95,54 @@ export default function SpacerControls( { } ) { return ( <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + width: undefined, + height: '100px', + } ); + } } + > { orientation === 'horizontal' && ( - <DimensionInput + <ToolsPanelItem label={ __( 'Width' ) } - value={ width } - onChange={ ( nextWidth ) => - setAttributes( { width: nextWidth } ) + isShownByDefault + hasValue={ () => width !== undefined } + onDeselect={ () => + setAttributes( { width: undefined } ) } - isResizing={ isResizing } - /> + > + <DimensionInput + label={ __( 'Width' ) } + value={ width } + onChange={ ( nextWidth ) => + setAttributes( { width: nextWidth } ) + } + isResizing={ isResizing } + /> + </ToolsPanelItem> ) } { orientation !== 'horizontal' && ( - <DimensionInput + <ToolsPanelItem label={ __( 'Height' ) } - value={ height } - onChange={ ( nextHeight ) => - setAttributes( { height: nextHeight } ) + isShownByDefault + hasValue={ () => height !== '100px' } + onDeselect={ () => + setAttributes( { height: '100px' } ) } - isResizing={ isResizing } - /> + > + <DimensionInput + label={ __( 'Height' ) } + value={ height } + onChange={ ( nextHeight ) => + setAttributes( { height: nextHeight } ) + } + isResizing={ isResizing } + /> + </ToolsPanelItem> ) } - </PanelBody> + </ToolsPanel> </InspectorControls> ); } diff --git a/packages/block-library/src/table/edit.js b/packages/block-library/src/table/edit.js index 1d61bab0787e44..a6c8be3c4a4899 100644 --- a/packages/block-library/src/table/edit.js +++ b/packages/block-library/src/table/edit.js @@ -57,6 +57,7 @@ import { isEmptyTableSection, } from './state'; import { Caption } from '../utils/caption'; +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; const ALIGNMENT_CONTROLS = [ { @@ -109,6 +110,8 @@ function TableEdit( { const tableRef = useRef(); const [ hasTableCreated, setHasTableCreated ] = useState( false ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + /** * Updates the initial column count used for table creation. * @@ -483,6 +486,7 @@ function TableEdit( { foot: [], } ); } } + dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem hasValue={ () => hasFixedLayout !== true } diff --git a/packages/block-library/src/tag-cloud/edit.js b/packages/block-library/src/tag-cloud/edit.js index eeb568e7a89ef1..7e544d2474f049 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'; @@ -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. @@ -118,10 +124,26 @@ function TagCloudEdit( { attributes, setAttributes } ) { const inspectorControls = ( <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> - <VStack - spacing={ 4 } - className="wp-block-tag-cloud__inspector-settings" + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + taxonomy: 'post_tag', + showTagCounts: false, + numberOfTags: 45, + smallestFontSize: '8pt', + largestFontSize: '22pt', + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > + <ToolsPanelItem + hasValue={ () => taxonomy !== 'post_tag' } + label={ __( 'Taxonomy' ) } + onDeselect={ () => + setAttributes( { taxonomy: 'post_tag' } ) + } + isShownByDefault > <SelectControl __nextHasNoMarginBottom @@ -133,6 +155,20 @@ function TagCloudEdit( { attributes, setAttributes } ) { setAttributes( { taxonomy: selectedTaxonomy } ) } /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => + smallestFontSize !== '8pt' || largestFontSize !== '22pt' + } + label={ __( 'Font size' ) } + onDeselect={ () => + setAttributes( { + smallestFontSize: '8pt', + largestFontSize: '22pt', + } ) + } + isShownByDefault + > <Flex gap={ 4 }> <FlexItem isBlock> <UnitControl @@ -167,6 +203,13 @@ function TagCloudEdit( { attributes, setAttributes } ) { /> </FlexItem> </Flex> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => numberOfTags !== 45 } + label={ __( 'Number of tags' ) } + onDeselect={ () => setAttributes( { numberOfTags: 45 } ) } + isShownByDefault + > <RangeControl __nextHasNoMarginBottom __next40pxDefaultSize @@ -179,6 +222,15 @@ function TagCloudEdit( { attributes, setAttributes } ) { max={ MAX_TAGS } required /> + </ToolsPanelItem> + <ToolsPanelItem + hasValue={ () => showTagCounts !== false } + label={ __( 'Show tag counts' ) } + onDeselect={ () => + setAttributes( { showTagCounts: false } ) + } + isShownByDefault + > <ToggleControl __nextHasNoMarginBottom label={ __( 'Show tag counts' ) } @@ -187,8 +239,8 @@ function TagCloudEdit( { attributes, setAttributes } ) { setAttributes( { showTagCounts: ! showTagCounts } ) } /> - </VStack> - </PanelBody> + </ToolsPanelItem> + </ToolsPanel> </InspectorControls> ); diff --git a/packages/block-library/src/tag-cloud/editor.scss b/packages/block-library/src/tag-cloud/editor.scss index e85129e22f1aca..d00a450174f2fd 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; - } -} diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js index c4589ce8232f66..e20938fd84e2b4 100644 --- a/packages/blocks/src/store/selectors.js +++ b/packages/blocks/src/store/selectors.js @@ -102,7 +102,7 @@ export const getBlockTypes = createSelector( * }; * ``` * - * @return {Object?} Block Type. + * @return {?Object} Block Type. */ export function getBlockType( state, name ) { return state.blockTypes[ name ]; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 49cc196b1f7e69..db8b0f749217f4 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -8,13 +8,25 @@ ### 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)). +- `RadioGroup`: Log deprecation warning ([#68067](https://github.com/WordPress/gutenberg/pull/68067)). +- Soft deprecate `ButtonGroup` component. Use `ToggleGroupControl` instead ([#65429](https://github.com/WordPress/gutenberg/pull/65429)). ### Bug Fixes - `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)). +- `Menu`: refactor to more granular sub-components ([#67422](https://github.com/WordPress/gutenberg/pull/67422)). + +### 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/badge/README.md b/packages/components/src/badge/README.md new file mode 100644 index 00000000000000..0be531ca6f2df8 --- /dev/null +++ b/packages/components/src/badge/README.md @@ -0,0 +1,22 @@ +# Badge + +<!-- This file is generated automatically and cannot be edited directly. Make edits via TypeScript types and TSDocs. --> + +<p class="callout callout-info">See the <a href="https://wordpress.github.io/gutenberg/?path=/docs/components-badge--docs">WordPress Storybook</a> for more detailed, interactive documentation.</p> + +## 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 00000000000000..3b70c0ef228432 --- /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 00000000000000..8a55f3881215f3 --- /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 ( + <span + className={ clsx( + 'components-badge', + `is-${ intent }`, + intent !== 'default' && 'has-icon', + className + ) } + { ...props } + > + { intent !== 'default' && ( + <Icon + icon={ contextBasedIcon() } + size={ 16 } + fill="currentColor" + /> + ) } + { children } + </span> + ); +} + +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 00000000000000..7f827d3bfabf5a --- /dev/null +++ b/packages/components/src/badge/stories/index.story.tsx @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import type { Meta, StoryObj } from '@storybook/react'; + +/** + * Internal dependencies + */ +import Badge from '..'; + +const meta = { + component: Badge, + title: 'Components/Containers/Badge', + id: 'components-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 00000000000000..e1e9cd5312d11a --- /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 00000000000000..47c832eb3c8300 --- /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( <Badge>Code is Poetry</Badge> ); + 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( <Badge intent="error">Code is Poetry</Badge> ); + 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( <Badge className="custom-class">Code is Poetry</Badge> ); + const badge = screen.getByText( 'Code is Poetry' ); + expect( badge ).toHaveClass( 'components-badge' ); + expect( badge ).toHaveClass( 'custom-class' ); + } ); + + it( 'should pass through additional props', () => { + render( <Badge data-testid="custom-badge">Code is Poetry</Badge> ); + 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 00000000000000..91cd7c39b549bb --- /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/button-group/README.md b/packages/components/src/button-group/README.md index 5c0179d6877af9..579103dc70e062 100644 --- a/packages/components/src/button-group/README.md +++ b/packages/components/src/button-group/README.md @@ -1,5 +1,9 @@ # ButtonGroup +<div class="callout callout-alert"> + This component is deprecated. Use `ToggleGroupControl` instead. +</div> + 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 fb2659c2a0d7de..e073b0c3b359b8 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 @@ -19,9 +20,16 @@ function UnforwardedButtonGroup( props: WordPressComponentProps< ButtonGroupProps, 'div', false >, ref: ForwardedRef< HTMLDivElement > ) { - const { className, ...restProps } = props; + const { className, __shouldNotWarnDeprecated, ...restProps } = props; const classes = clsx( 'components-button-group', className ); + if ( ! __shouldNotWarnDeprecated ) { + deprecated( 'wp.components.ButtonGroup', { + since: '6.8', + alternative: 'wp.components.__experimentalToggleGroupControl', + } ); + } + return ( <div ref={ ref } role="group" className={ classes } { ...restProps } /> ); @@ -31,6 +39,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 4b5ab3d5dfdb6b..a2df76004d4385 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 }, diff --git a/packages/components/src/button-group/types.ts b/packages/components/src/button-group/types.ts index 0bc162d5cf1c74..57388c7b5fc095 100644 --- a/packages/components/src/button-group/types.ts +++ b/packages/components/src/button-group/types.ts @@ -8,4 +8,11 @@ export type ButtonGroupProps = { * The children elements. */ children: ReactNode; + /** + * Do not throw a warning for component deprecation. + * For internal components of other components that already throw the warning. + * + * @ignore + */ + __shouldNotWarnDeprecated?: boolean; }; diff --git a/packages/components/src/input-control/types.ts b/packages/components/src/input-control/types.ts index 99c5b1aea92c37..edb69def619057 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 `<InputControlPrefixWrapper>` component. * - * @example + * ```jsx * import { * __experimentalInputControl as InputControl, * __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper, @@ -145,6 +145,7 @@ export interface InputBaseProps extends BaseProps, FlexProps { * <InputControl * prefix={<InputControlPrefixWrapper>@</InputControlPrefixWrapper>} * /> + * ``` */ 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 `<InputControlSuffixWrapper>` component. * - * @example + * ```jsx * import { * __experimentalInputControl as InputControl, * __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper, @@ -163,6 +164,7 @@ export interface InputBaseProps extends BaseProps, FlexProps { * <InputControl * suffix={<InputControlSuffixWrapper>%</InputControlSuffixWrapper>} * /> + * ``` */ suffix?: ReactNode; /** diff --git a/packages/components/src/menu/index.tsx b/packages/components/src/menu/index.tsx index 9886f324823212..2e0fc91cfbc34f 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 */ } - <Ariakit.MenuButton - ref={ ref } - store={ menuStore } - render={ - menuStore.parent - ? cloneElement( trigger, { - // Add submenu arrow, unless a `suffix` is explicitly specified - suffix: ( - <> - { trigger.props.suffix } - <Styled.SubmenuChevronIcon - aria-hidden="true" - icon={ chevronRightSmall } - size={ 24 } - preserveAspectRatio="xMidYMid slice" - /> - </> - ), - } ) - : trigger - } - /> - - { /* Menu popover */ } - <Ariakit.Menu - { ...otherProps } - modal={ modal } - store={ menuStore } - // Root menu has an 8px distance from its trigger, - // otherwise 0 (which causes the submenu to slightly overlap) - gutter={ gutter ?? ( menuStore.parent ? 0 : 8 ) } - // Align nested menu by the same (but opposite) amount - // as the menu container's padding. - shift={ shift ?? ( menuStore.parent ? -4 : 0 ) } - hideOnHoverOutside={ false } - data-side={ appliedPlacementSide } - wrapperProps={ wrapperProps } - hideOnEscape={ hideOnEscape } - unmountOnHide - render={ ( renderProps ) => ( - // 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. - <Styled.MenuPopoverOuterWrapper variant={ variant }> - <Styled.MenuPopoverInnerWrapper { ...renderProps } /> - </Styled.MenuPopoverOuterWrapper> - ) } - > - <MenuContext.Provider value={ contextValue }> - { children } - </MenuContext.Provider> - </Ariakit.Menu> - </> + <MenuContext.Provider value={ contextValue }> + { children } + </MenuContext.Provider> ); }; -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 6d09bdf3d0f591..84ff050bcc2236 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 ( <Styled.MenuItem ref={ ref } { ...props } accessibleWhenDisabled hideOnClick={ hideOnClick } - store={ menuContext.store } + store={ computedStore } > <Styled.ItemPrefixWrapper>{ prefix }</Styled.ItemPrefixWrapper> diff --git a/packages/components/src/menu/popover.tsx b/packages/components/src/menu/popover.tsx new file mode 100644 index 00000000000000..19972a31027ce1 --- /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 ( + <Ariakit.Menu + { ...otherProps } + ref={ ref } + modal={ modal } + store={ menuContext.store } + // Root menu has an 8px distance from its trigger, + // otherwise 0 (which causes the submenu to slightly overlap) + gutter={ gutter ?? ( menuContext.store.parent ? 0 : 8 ) } + // Align nested menu by the same (but opposite) amount + // as the menu container's padding. + shift={ shift ?? ( menuContext.store.parent ? -4 : 0 ) } + hideOnHoverOutside={ false } + data-side={ appliedPlacementSide } + wrapperProps={ wrapperProps } + hideOnEscape={ hideOnEscape } + unmountOnHide + render={ ( renderProps ) => ( + // 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. + <Styled.MenuPopoverOuterWrapper variant={ menuContext.variant }> + <Styled.MenuPopoverInnerWrapper { ...renderProps } /> + </Styled.MenuPopoverOuterWrapper> + ) } + > + { children } + </Ariakit.Menu> + ); +} ); diff --git a/packages/components/src/menu/stories/index.story.tsx b/packages/components/src/menu/stories/index.story.tsx index ad4794057e0e03..dcd890370a1e0a 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 ) => ( <Menu { ...props }> - <Menu.Item> - <Menu.ItemLabel>Label</Menu.ItemLabel> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Label</Menu.ItemLabel> - <Menu.ItemHelpText>Help text</Menu.ItemHelpText> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Label</Menu.ItemLabel> - <Menu.ItemHelpText> - The menu item help text is automatically truncated when there - are more than two lines of text - </Menu.ItemHelpText> - </Menu.Item> - <Menu.Item hideOnClick={ false }> - <Menu.ItemLabel>Label</Menu.ItemLabel> - <Menu.ItemHelpText> - This item doesn&apos;t close the menu on click - </Menu.ItemHelpText> - </Menu.Item> - <Menu.Item disabled>Disabled item</Menu.Item> - <Menu.Separator /> - <Menu.Group> - <Menu.GroupLabel>Group label</Menu.GroupLabel> - <Menu.Item prefix={ <Icon icon={ customLink } size={ 24 } /> }> - <Menu.ItemLabel>With prefix</Menu.ItemLabel> + <Menu.TriggerButton + render={ <Button __next40pxDefaultSize variant="secondary" /> } + > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel>Label</Menu.ItemLabel> </Menu.Item> - <Menu.Item suffix="⌘S">With suffix</Menu.Item> - <Menu.Item - disabled - prefix={ <Icon icon={ formatCapitalize } size={ 24 } /> } - suffix="⌥⌘T" - > - <Menu.ItemLabel>Disabled with prefix and suffix</Menu.ItemLabel> - <Menu.ItemHelpText>And help text</Menu.ItemHelpText> + <Menu.Item> + <Menu.ItemLabel>Label</Menu.ItemLabel> + <Menu.ItemHelpText>Help text</Menu.ItemHelpText> </Menu.Item> - </Menu.Group> + <Menu.Item> + <Menu.ItemLabel>Label</Menu.ItemLabel> + <Menu.ItemHelpText> + The menu item help text is automatically truncated when + there are more than two lines of text + </Menu.ItemHelpText> + </Menu.Item> + <Menu.Item hideOnClick={ false }> + <Menu.ItemLabel>Label</Menu.ItemLabel> + <Menu.ItemHelpText> + This item doesn&apos;t close the menu on click + </Menu.ItemHelpText> + </Menu.Item> + <Menu.Item disabled>Disabled item</Menu.Item> + <Menu.Separator /> + <Menu.Group> + <Menu.GroupLabel>Group label</Menu.GroupLabel> + <Menu.Item prefix={ <Icon icon={ customLink } size={ 24 } /> }> + <Menu.ItemLabel>With prefix</Menu.ItemLabel> + </Menu.Item> + <Menu.Item suffix="⌘S">With suffix</Menu.Item> + <Menu.Item + disabled + prefix={ <Icon icon={ formatCapitalize } size={ 24 } /> } + suffix="⌥⌘T" + > + <Menu.ItemLabel> + Disabled with prefix and suffix + </Menu.ItemLabel> + <Menu.ItemHelpText>And help text</Menu.ItemHelpText> + </Menu.Item> + </Menu.Group> + </Menu.Popover> </Menu> ); -Default.args = { - trigger: ( - <Button __next40pxDefaultSize variant="secondary"> - Open menu - </Button> - ), -}; +Default.args = {}; -export const WithSubmenu: StoryFn< typeof Menu > = ( props ) => ( +export const WithSubmenu: StoryFn< typeof Menu > = ( props: MenuProps ) => ( <Menu { ...props }> - <Menu.Item>Level 1 item</Menu.Item> - <Menu - trigger={ - <Menu.Item suffix="Suffix"> + <Menu.TriggerButton + render={ <Button __next40pxDefaultSize variant="secondary" /> } + > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Level 1 item</Menu.Item> + <Menu> + <Menu.SubmenuTriggerItem suffix="Suffix"> <Menu.ItemLabel> Submenu trigger item with a long label </Menu.ItemLabel> - </Menu.Item> - } - > - <Menu.Item> - <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> - </Menu.Item> - <Menu - trigger={ + </Menu.SubmenuTriggerItem> + <Menu.Popover> <Menu.Item> - <Menu.ItemLabel>Submenu trigger</Menu.ItemLabel> + <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> </Menu.Item> - } - > - <Menu.Item> - <Menu.ItemLabel>Level 3 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Level 3 item</Menu.ItemLabel> - </Menu.Item> + <Menu.Item> + <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> + </Menu.Item> + <Menu> + <Menu.SubmenuTriggerItem> + <Menu.ItemLabel>Submenu trigger</Menu.ItemLabel> + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel>Level 3 item</Menu.ItemLabel> + </Menu.Item> + <Menu.Item> + <Menu.ItemLabel>Level 3 item</Menu.ItemLabel> + </Menu.Item> + </Menu.Popover> + </Menu> + </Menu.Popover> </Menu> - </Menu> + </Menu.Popover> </Menu> ); 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 ( <Menu { ...props }> - <Menu.Group> - <Menu.GroupLabel> - Single selection, uncontrolled - </Menu.GroupLabel> - <Menu.CheckboxItem - name="checkbox-individual-uncontrolled-a" - value="a" - suffix="⌥⌘T" - > - <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.CheckboxItem> - <Menu.CheckboxItem - name="checkbox-individual-uncontrolled-b" - value="b" - defaultChecked - > - <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.CheckboxItem> - </Menu.Group> - <Menu.Separator /> - <Menu.Group> - <Menu.GroupLabel>Single selection, controlled</Menu.GroupLabel> - <Menu.CheckboxItem - name="checkbox-individual-controlled-a" - value="a" - checked={ isAChecked } - onChange={ ( e ) => setAChecked( e.target.checked ) } - > - <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.CheckboxItem> - <Menu.CheckboxItem - name="checkbox-individual-controlled-b" - value="b" - checked={ isBChecked } - onChange={ ( e ) => setBChecked( e.target.checked ) } - > - <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.CheckboxItem> - </Menu.Group> - <Menu.Separator /> - <Menu.Group> - <Menu.GroupLabel> - Multiple selection, uncontrolled - </Menu.GroupLabel> - <Menu.CheckboxItem - name="checkbox-multiple-uncontrolled" - value="a" - > - <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.CheckboxItem> - <Menu.CheckboxItem - name="checkbox-multiple-uncontrolled" - value="b" - defaultChecked - > - <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.CheckboxItem> - </Menu.Group> - <Menu.Separator /> - <Menu.Group> - <Menu.GroupLabel> - Multiple selection, controlled - </Menu.GroupLabel> - <Menu.CheckboxItem - name="checkbox-multiple-controlled" - value="a" - checked={ multipleCheckboxesValue.includes( 'a' ) } - onChange={ onMultipleCheckboxesCheckedChange } - > - <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.CheckboxItem> - <Menu.CheckboxItem - name="checkbox-multiple-controlled" - value="b" - checked={ multipleCheckboxesValue.includes( 'b' ) } - onChange={ onMultipleCheckboxesCheckedChange } - > - <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.CheckboxItem> - </Menu.Group> + <Menu.TriggerButton + render={ <Button __next40pxDefaultSize variant="secondary" /> } + > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Group> + <Menu.GroupLabel> + Single selection, uncontrolled + </Menu.GroupLabel> + <Menu.CheckboxItem + name="checkbox-individual-uncontrolled-a" + value="a" + suffix="⌥⌘T" + > + <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.CheckboxItem> + <Menu.CheckboxItem + name="checkbox-individual-uncontrolled-b" + value="b" + defaultChecked + > + <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.CheckboxItem> + </Menu.Group> + <Menu.Separator /> + <Menu.Group> + <Menu.GroupLabel> + Single selection, controlled + </Menu.GroupLabel> + <Menu.CheckboxItem + name="checkbox-individual-controlled-a" + value="a" + checked={ isAChecked } + onChange={ ( e ) => { + setAChecked( e.target.checked ); + } } + > + <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.CheckboxItem> + <Menu.CheckboxItem + name="checkbox-individual-controlled-b" + value="b" + checked={ isBChecked } + onChange={ ( e ) => setBChecked( e.target.checked ) } + > + <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.CheckboxItem> + </Menu.Group> + <Menu.Separator /> + <Menu.Group> + <Menu.GroupLabel> + Multiple selection, uncontrolled + </Menu.GroupLabel> + <Menu.CheckboxItem + name="checkbox-multiple-uncontrolled" + value="a" + > + <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.CheckboxItem> + <Menu.CheckboxItem + name="checkbox-multiple-uncontrolled" + value="b" + defaultChecked + > + <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.CheckboxItem> + </Menu.Group> + <Menu.Separator /> + <Menu.Group> + <Menu.GroupLabel> + Multiple selection, controlled + </Menu.GroupLabel> + <Menu.CheckboxItem + name="checkbox-multiple-controlled" + value="a" + checked={ multipleCheckboxesValue.includes( 'a' ) } + onChange={ onMultipleCheckboxesCheckedChange } + > + <Menu.ItemLabel>Checkbox item A</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.CheckboxItem> + <Menu.CheckboxItem + name="checkbox-multiple-controlled" + value="b" + checked={ multipleCheckboxesValue.includes( 'b' ) } + onChange={ onMultipleCheckboxesCheckedChange } + > + <Menu.ItemLabel>Checkbox item B</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.CheckboxItem> + </Menu.Group> + </Menu.Popover> </Menu> ); }; @@ -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 ( <Menu { ...props }> - <Menu.Group> - <Menu.GroupLabel>Uncontrolled</Menu.GroupLabel> - <Menu.RadioItem name="radio-uncontrolled" value="one"> - <Menu.ItemLabel>Radio item 1</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.RadioItem> - <Menu.RadioItem - name="radio-uncontrolled" - value="two" - defaultChecked - > - <Menu.ItemLabel>Radio item 2</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.RadioItem> - </Menu.Group> - <Menu.Separator /> - <Menu.Group> - <Menu.GroupLabel>Controlled</Menu.GroupLabel> - <Menu.RadioItem - name="radio-controlled" - value="one" - checked={ radioValue === 'one' } - onChange={ onRadioChange } - > - <Menu.ItemLabel>Radio item 1</Menu.ItemLabel> - <Menu.ItemHelpText>Initially unchecked</Menu.ItemHelpText> - </Menu.RadioItem> - <Menu.RadioItem - name="radio-controlled" - value="two" - checked={ radioValue === 'two' } - onChange={ onRadioChange } - > - <Menu.ItemLabel>Radio item 2</Menu.ItemLabel> - <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> - </Menu.RadioItem> - </Menu.Group> + <Menu.TriggerButton + render={ <Button __next40pxDefaultSize variant="secondary" /> } + > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Group> + <Menu.GroupLabel>Uncontrolled</Menu.GroupLabel> + <Menu.RadioItem name="radio-uncontrolled" value="one"> + <Menu.ItemLabel>Radio item 1</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.RadioItem> + <Menu.RadioItem + name="radio-uncontrolled" + value="two" + defaultChecked + > + <Menu.ItemLabel>Radio item 2</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.RadioItem> + </Menu.Group> + <Menu.Separator /> + <Menu.Group> + <Menu.GroupLabel>Controlled</Menu.GroupLabel> + <Menu.RadioItem + name="radio-controlled" + value="one" + checked={ radioValue === 'one' } + onChange={ onRadioChange } + > + <Menu.ItemLabel>Radio item 1</Menu.ItemLabel> + <Menu.ItemHelpText> + Initially unchecked + </Menu.ItemHelpText> + </Menu.RadioItem> + <Menu.RadioItem + name="radio-controlled" + value="two" + checked={ radioValue === 'two' } + onChange={ onRadioChange } + > + <Menu.ItemLabel>Radio item 2</Menu.ItemLabel> + <Menu.ItemHelpText>Initially checked</Menu.ItemHelpText> + </Menu.RadioItem> + </Menu.Group> + </Menu.Popover> </Menu> ); }; @@ -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 ( <> <Menu { ...props }> - <Menu.Item - onClick={ () => setOuterModalOpen( true ) } - hideOnClick={ false } - > - <Menu.ItemLabel>Open outer modal</Menu.ItemLabel> - </Menu.Item> - <Menu.Item - onClick={ () => setInnerModalOpen( true ) } - hideOnClick={ false } + <Menu.TriggerButton + render={ + <Button __next40pxDefaultSize variant="secondary" /> + } > - <Menu.ItemLabel>Open inner modal</Menu.ItemLabel> - </Menu.Item> - { isInnerModalOpen && ( - <Modal - onRequestClose={ () => setInnerModalOpen( false ) } - overlayClassName={ modalOverlayClassName } + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Item + onClick={ () => setOuterModalOpen( true ) } + hideOnClick={ false } > - Modal&apos;s contents - <button onClick={ () => setInnerModalOpen( false ) }> - Close - </button> - </Modal> - ) } + <Menu.ItemLabel>Open outer modal</Menu.ItemLabel> + </Menu.Item> + <Menu.Item + onClick={ () => setInnerModalOpen( true ) } + hideOnClick={ false } + > + <Menu.ItemLabel>Open inner modal</Menu.ItemLabel> + </Menu.Item> + { isInnerModalOpen && ( + <Modal + onRequestClose={ () => setInnerModalOpen( false ) } + overlayClassName={ modalOverlayClassName } + > + Modal&apos;s contents + <button + onClick={ () => setInnerModalOpen( false ) } + > + Close + </button> + </Modal> + ) } + </Menu.Popover> </Menu> { isOuterModalOpen && ( <Modal @@ -423,30 +478,40 @@ const Fill = ( { children }: { children: React.ReactNode } ) => { ); }; -export const WithSlotFill: StoryFn< typeof Menu > = ( props ) => { +export const WithSlotFill: StoryFn< typeof Menu > = ( props: MenuProps ) => { return ( <SlotFillProvider> <Menu { ...props }> - <Menu.Item> - <Menu.ItemLabel>Item</Menu.ItemLabel> - </Menu.Item> - <Slot /> + <Menu.TriggerButton + render={ + <Button __next40pxDefaultSize variant="secondary" /> + } + > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel>Item</Menu.ItemLabel> + </Menu.Item> + <Slot /> + </Menu.Popover> </Menu> <Fill> <Menu.Item> <Menu.ItemLabel>Item from fill</Menu.ItemLabel> </Menu.Item> - <Menu - trigger={ + <Menu> + <Menu.SubmenuTriggerItem> + <Menu.ItemLabel>Submenu from fill</Menu.ItemLabel> + </Menu.SubmenuTriggerItem> + <Menu.Popover> <Menu.Item> - <Menu.ItemLabel>Submenu from fill</Menu.ItemLabel> + <Menu.ItemLabel> + Submenu item from fill + </Menu.ItemLabel> </Menu.Item> - } - > - <Menu.Item> - <Menu.ItemLabel>Submenu item from fill</Menu.ItemLabel> - </Menu.Item> + </Menu.Popover> </Menu> </Fill> </SlotFillProvider> @@ -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 <ContextSystemProvider value={ toolbarVariantContextValue }> <Menu { ...props }> - <Menu.Item> - <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Separator /> - <Menu - trigger={ - <Menu.Item> - <Menu.ItemLabel>Submenu trigger</Menu.ItemLabel> - </Menu.Item> - } + <Menu.TriggerButton + render={ <Button __next40pxDefaultSize variant="secondary" /> } > + Open menu + </Menu.TriggerButton> + <Menu.Popover> <Menu.Item> - <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> + <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> </Menu.Item> - </Menu> + <Menu.Item> + <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> + </Menu.Item> + <Menu.Separator /> + <Menu> + <Menu.SubmenuTriggerItem> + <Menu.ItemLabel>Submenu trigger</Menu.ItemLabel> + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> + </Menu.Item> + </Menu.Popover> + </Menu> + </Menu.Popover> </Menu> </ContextSystemProvider> ); @@ -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 </Button> { isModalOpen && ( - <Modal onRequestClose={ () => setModalOpen( false ) }> + <Modal + onRequestClose={ () => setModalOpen( false ) } + title="Menu inside modal" + > <Menu { ...props }> - <Menu.Item> - <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Item> - <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> - </Menu.Item> - <Menu.Separator /> - <Menu - trigger={ - <Menu.Item> - <Menu.ItemLabel> - Submenu trigger - </Menu.ItemLabel> - </Menu.Item> + <Menu.TriggerButton + render={ + <Button + __next40pxDefaultSize + variant="secondary" + /> } > + Open menu + </Menu.TriggerButton> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> + </Menu.Item> <Menu.Item> - <Menu.ItemLabel>Level 2 item</Menu.ItemLabel> + <Menu.ItemLabel>Level 1 item</Menu.ItemLabel> </Menu.Item> - </Menu> + <Menu.Separator /> + <Menu> + <Menu.SubmenuTriggerItem> + <Menu.ItemLabel> + Submenu trigger + </Menu.ItemLabel> + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item> + <Menu.ItemLabel> + Level 2 item + </Menu.ItemLabel> + </Menu.Item> + </Menu.Popover> + </Menu> + </Menu.Popover> </Menu> <Button onClick={ () => 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 00000000000000..23932a14bdaff4 --- /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 ( + <Ariakit.MenuButton + ref={ ref } + accessibleWhenDisabled + store={ menuContext.store } + render={ + <MenuItem + { ...otherProps } + // The menu item needs to register and be part of the parent menu. + // Without specifying the store explicitly, the `MenuItem` component + // would otherwise read the store via context and pick up the one from + // the sub-menu `Menu` component. + store={ menuContext.store.parent } + suffix={ + <> + { suffix } + <Styled.SubmenuChevronIcon + aria-hidden="true" + icon={ chevronRightSmall } + size={ 24 } + preserveAspectRatio="xMidYMid slice" + /> + </> + } + /> + } + /> + ); +} ); diff --git a/packages/components/src/menu/test/index.tsx b/packages/components/src/menu/test/index.tsx index 60276cdb2379a0..42e1516d94bbba 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( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> - <Menu.Separator /> - <Menu trigger={ <Menu.Item>Submenu trigger item</Menu.Item> }> - <Menu.Item>Submenu item 1</Menu.Item> - <Menu.Item>Submenu item 2</Menu.Item> - </Menu> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + <Menu.Separator /> + <Menu> + <Menu.SubmenuTriggerItem> + Submenu trigger item + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item>Submenu item 1</Menu.Item> + <Menu.Item>Submenu item 2</Menu.Item> + </Menu.Popover> + </Menu> + </Menu.Popover> </Menu> ); @@ -84,8 +95,11 @@ describe( 'Menu', () => { describe( 'pointer and keyboard interactions', () => { it( 'should open and focus the menu when clicking the trigger', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -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( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item disabled>First item</Menu.Item> - <Menu.Item>Second item</Menu.Item> - <Menu.Item>Third item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item disabled>First item</Menu.Item> + <Menu.Item>Second item</Menu.Item> + <Menu.Item>Third item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -135,10 +152,13 @@ describe( 'Menu', () => { it( 'should open and focus the first item when pressing the space key on the trigger', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item disabled>First item</Menu.Item> - <Menu.Item>Second item</Menu.Item> - <Menu.Item>Third item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item disabled>First item</Menu.Item> + <Menu.Item>Second item</Menu.Item> + <Menu.Item>Third item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -165,8 +185,11 @@ describe( 'Menu', () => { it( 'should close when pressing the escape key', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -194,8 +217,11 @@ describe( 'Menu', () => { it( 'should close when clicking outside of the content', async () => { render( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -209,8 +235,11 @@ describe( 'Menu', () => { it( 'should close when clicking on a menu item', async () => { render( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -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( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item hideOnClick={ false }>Menu item</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item hideOnClick={ false }>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -239,8 +271,11 @@ describe( 'Menu', () => { it( 'should not close when clicking on a disabled menu item', async () => { render( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item disabled>Menu item</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item disabled>Menu item</Menu.Item> + </Menu.Popover> </Menu> ); @@ -254,16 +289,22 @@ describe( 'Menu', () => { it( 'should reveal submenu content when hovering over the submenu trigger', async () => { render( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item 1</Menu.Item> - <Menu.Item>Menu item 2</Menu.Item> - <Menu - trigger={ <Menu.Item>Submenu trigger item</Menu.Item> } - > - <Menu.Item>Submenu item 1</Menu.Item> - <Menu.Item>Submenu item 2</Menu.Item> - </Menu> - <Menu.Item>Menu item 3</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item 1</Menu.Item> + <Menu.Item>Menu item 2</Menu.Item> + <Menu> + <Menu.SubmenuTriggerItem> + Submenu trigger item + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item>Submenu item 1</Menu.Item> + <Menu.Item>Submenu item 2</Menu.Item> + </Menu.Popover> + </Menu> + <Menu.Item>Menu item 3</Menu.Item> + </Menu.Popover> </Menu> ); @@ -288,16 +329,22 @@ describe( 'Menu', () => { it( 'should navigate menu items and subitems using the arrow, spacebar and enter keys', async () => { render( - <Menu defaultOpen trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item 1</Menu.Item> - <Menu.Item>Menu item 2</Menu.Item> - <Menu - trigger={ <Menu.Item>Submenu trigger item</Menu.Item> } - > - <Menu.Item>Submenu item 1</Menu.Item> - <Menu.Item>Submenu item 2</Menu.Item> - </Menu> - <Menu.Item>Menu item 3</Menu.Item> + <Menu defaultOpen> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item 1</Menu.Item> + <Menu.Item>Menu item 2</Menu.Item> + <Menu> + <Menu.SubmenuTriggerItem> + Submenu trigger item + </Menu.SubmenuTriggerItem> + <Menu.Popover> + <Menu.Item>Submenu item 1</Menu.Item> + <Menu.Item>Submenu item 2</Menu.Item> + </Menu.Popover> + </Menu> + <Menu.Item>Menu item 3</Menu.Item> + </Menu.Popover> </Menu> ); @@ -407,25 +454,28 @@ describe( 'Menu', () => { setRadioValue( e.target.value ); }; return ( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Group> - <Menu.RadioItem - name="radio-test" - value="radio-one" - checked={ radioValue === 'radio-one' } - onChange={ onRadioChange } - > - Radio item one - </Menu.RadioItem> - <Menu.RadioItem - name="radio-test" - value="radio-two" - checked={ radioValue === 'radio-two' } - onChange={ onRadioChange } - > - Radio item two - </Menu.RadioItem> - </Menu.Group> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Group> + <Menu.RadioItem + name="radio-test" + value="radio-one" + checked={ radioValue === 'radio-one' } + onChange={ onRadioChange } + > + Radio item one + </Menu.RadioItem> + <Menu.RadioItem + name="radio-test" + value="radio-two" + checked={ radioValue === 'radio-two' } + onChange={ onRadioChange } + > + Radio item two + </Menu.RadioItem> + </Menu.Group> + </Menu.Popover> </Menu> ); }; @@ -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( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Group> - <Menu.RadioItem - name="radio-test" - value="radio-one" - onChange={ ( e ) => - onRadioValueChangeSpy( e.target.value ) - } - > - Radio item one - </Menu.RadioItem> - <Menu.RadioItem - name="radio-test" - value="radio-two" - defaultChecked - onChange={ ( e ) => - onRadioValueChangeSpy( e.target.value ) - } - > - Radio item two - </Menu.RadioItem> - </Menu.Group> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Group> + <Menu.RadioItem + name="radio-test" + value="radio-one" + onChange={ ( e ) => + onRadioValueChangeSpy( e.target.value ) + } + > + Radio item one + </Menu.RadioItem> + <Menu.RadioItem + name="radio-test" + value="radio-two" + defaultChecked + onChange={ ( e ) => + onRadioValueChangeSpy( e.target.value ) + } + > + Radio item two + </Menu.RadioItem> + </Menu.Group> + </Menu.Popover> </Menu> ); @@ -568,38 +621,41 @@ describe( 'Menu', () => { useState< boolean >(); return ( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.CheckboxItem - name="item-one" - value="item-one-value" - checked={ itemOneChecked } - onChange={ ( e ) => { - onCheckboxValueChangeSpy( - e.target.name, - e.target.value, - e.target.checked - ); - setItemOneChecked( e.target.checked ); - } } - > - Checkbox item one - </Menu.CheckboxItem> - - <Menu.CheckboxItem - name="item-two" - value="item-two-value" - checked={ itemTwoChecked } - onChange={ ( e ) => { - onCheckboxValueChangeSpy( - e.target.name, - e.target.value, - e.target.checked - ); - setItemTwoChecked( e.target.checked ); - } } - > - Checkbox item two - </Menu.CheckboxItem> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.CheckboxItem + name="item-one" + value="item-one-value" + checked={ itemOneChecked } + onChange={ ( e ) => { + onCheckboxValueChangeSpy( + e.target.name, + e.target.value, + e.target.checked + ); + setItemOneChecked( e.target.checked ); + } } + > + Checkbox item one + </Menu.CheckboxItem> + + <Menu.CheckboxItem + name="item-two" + value="item-two-value" + checked={ itemTwoChecked } + onChange={ ( e ) => { + onCheckboxValueChangeSpy( + e.target.name, + e.target.value, + e.target.checked + ); + setItemTwoChecked( e.target.checked ); + } } + > + Checkbox item two + </Menu.CheckboxItem> + </Menu.Popover> </Menu> ); }; @@ -691,35 +747,38 @@ describe( 'Menu', () => { const onCheckboxValueChangeSpy = jest.fn(); render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.CheckboxItem - name="item-one" - value="item-one-value" - onChange={ ( e ) => { - onCheckboxValueChangeSpy( - e.target.name, - e.target.value, - e.target.checked - ); - } } - > - Checkbox item one - </Menu.CheckboxItem> - - <Menu.CheckboxItem - name="item-two" - value="item-two-value" - defaultChecked - onChange={ ( e ) => { - onCheckboxValueChangeSpy( - e.target.name, - e.target.value, - e.target.checked - ); - } } - > - Checkbox item two - </Menu.CheckboxItem> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.CheckboxItem + name="item-one" + value="item-one-value" + onChange={ ( e ) => { + onCheckboxValueChangeSpy( + e.target.name, + e.target.value, + e.target.checked + ); + } } + > + Checkbox item one + </Menu.CheckboxItem> + + <Menu.CheckboxItem + name="item-two" + value="item-two-value" + defaultChecked + onChange={ ( e ) => { + onCheckboxValueChangeSpy( + e.target.name, + e.target.value, + e.target.checked + ); + } } + > + Checkbox item two + </Menu.CheckboxItem> + </Menu.Popover> </Menu> ); @@ -809,8 +868,11 @@ describe( 'Menu', () => { it( 'should be modal by default', async () => { render( <> - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> <button>Button outside of dropdown</button> </> @@ -836,11 +898,11 @@ describe( 'Menu', () => { it( 'should not be modal when the `modal` prop is set to `false`', async () => { render( <> - <Menu - trigger={ <button>Open dropdown</button> } - modal={ false } - > - <Menu.Item>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover modal={ false }> + <Menu.Item>Menu item</Menu.Item> + </Menu.Popover> </Menu> <button>Button outside of dropdown</button> </> @@ -873,8 +935,13 @@ describe( 'Menu', () => { describe( 'items prefix and suffix', () => { it( 'should display a prefix on regular items', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item prefix={ <>Item prefix</> }>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item prefix={ <>Item prefix</> }> + Menu item + </Menu.Item> + </Menu.Popover> </Menu> ); @@ -895,8 +962,13 @@ describe( 'Menu', () => { it( 'should display a suffix on regular items', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item suffix={ <>Item suffix</> }>Menu item</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item suffix={ <>Item suffix</> }> + Menu item + </Menu.Item> + </Menu.Popover> </Menu> ); @@ -917,14 +989,17 @@ describe( 'Menu', () => { it( 'should display a suffix on radio items', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.RadioItem - name="radio-test" - value="radio-one" - suffix="Radio suffix" - > - Radio item one - </Menu.RadioItem> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.RadioItem + name="radio-test" + value="radio-one" + suffix="Radio suffix" + > + Radio item one + </Menu.RadioItem> + </Menu.Popover> </Menu> ); @@ -945,14 +1020,17 @@ describe( 'Menu', () => { it( 'should display a suffix on checkbox items', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.CheckboxItem - name="checkbox-test" - value="checkbox-one" - suffix="Checkbox suffix" - > - Checkbox item one - </Menu.CheckboxItem> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.CheckboxItem + name="checkbox-test" + value="checkbox-one" + suffix="Checkbox suffix" + > + Checkbox item one + </Menu.CheckboxItem> + </Menu.Popover> </Menu> ); @@ -975,9 +1053,12 @@ describe( 'Menu', () => { describe( 'typeahead', () => { it( 'should highlight matching item', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>One</Menu.Item> - <Menu.Item>Two</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>One</Menu.Item> + <Menu.Item>Two</Menu.Item> + </Menu.Popover> </Menu> ); @@ -1008,9 +1089,12 @@ describe( 'Menu', () => { it( 'should keep previous focus when no matches are found', async () => { render( - <Menu trigger={ <button>Open dropdown</button> }> - <Menu.Item>One</Menu.Item> - <Menu.Item>Two</Menu.Item> + <Menu> + <Menu.TriggerButton>Open dropdown</Menu.TriggerButton> + <Menu.Popover> + <Menu.Item>One</Menu.Item> + <Menu.Item>Two</Menu.Item> + </Menu.Popover> </Menu> ); diff --git a/packages/components/src/menu/trigger-button.tsx b/packages/components/src/menu/trigger-button.tsx new file mode 100644 index 00000000000000..b99804efef0f17 --- /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 ( + <Ariakit.MenuButton + ref={ ref } + { ...props } + disabled={ disabled } + store={ menuContext.store } + > + { children } + </Ariakit.MenuButton> + ); +} ); diff --git a/packages/components/src/menu/types.ts b/packages/components/src/menu/types.ts index 7b58cef241743e..f58b5bcc89b953 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/components/src/private-apis.ts b/packages/components/src/private-apis.ts index 2ced100dc576be..f5a9ee90519c2d 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/radio-group/index.tsx b/packages/components/src/radio-group/index.tsx index e59775c00a8023..589d20ffdaae5b 100644 --- a/packages/components/src/radio-group/index.tsx +++ b/packages/components/src/radio-group/index.tsx @@ -6,6 +6,7 @@ import * as Ariakit from '@ariakit/react'; /** * WordPress dependencies */ +import deprecated from '@wordpress/deprecated'; import { useMemo, forwardRef } from '@wordpress/element'; import { isRTL } from '@wordpress/i18n'; @@ -46,11 +47,21 @@ function UnforwardedRadioGroup( [ radioStore, disabled ] ); + deprecated( 'wp.components.__experimentalRadioGroup', { + alternative: + 'wp.components.RadioControl or wp.components.__experimentalToggleGroupControl', + since: '6.8', + } ); + return ( <RadioGroupContext.Provider value={ contextValue }> <Ariakit.RadioGroup store={ radioStore } - render={ <ButtonGroup>{ children }</ButtonGroup> } + render={ + <ButtonGroup __shouldNotWarnDeprecated> + { children } + </ButtonGroup> + } aria-label={ label } ref={ ref } { ...props } diff --git a/packages/components/src/slot-fill/context.ts b/packages/components/src/slot-fill/context.ts index c4839462fbce0c..b1f0718180e9eb 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 0a31c8276b3f10..0bd1aec8fa3e0e 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 e2b98e73e1b707..e5319bc7f33e44 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 fe4a741ddbfbad..82feaa04199f51 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 6668057323edd9..758f1c8257d548 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 4ab419be1ad2bd..00000000000000 --- 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; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 70317f4a2d0e0b..368dec0f5e253d 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/components/src/tree-select/README.md b/packages/components/src/tree-select/README.md index 3d26488478bd0c..493c83bf993b0c 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. +<!-- This file is generated automatically and cannot be edited directly. Make edits via TypeScript types and TSDocs. --> -## Usage +<p class="callout callout-info">See the <a href="https://wordpress.github.io/gutenberg/?path=/docs/components-treeselect--docs">WordPress Storybook</a> for more detailed, interactive documentation.</p> -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 ( <TreeSelect - __nextHasNoMarginBottom + __nextHasNoMarginBottom + __next40pxDefaultSize label="Parent page" noOptionLabel="No parent page" onChange={ ( newPage ) => 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<HTMLSelectElement>; }) => 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 +`<option>` attributes. -### onChange + - Type: `readonly ({ label: string; value: string; } & Omit<OptionHTMLAttributes<HTMLOptionElement>, "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 `<InputControlPrefixWrapper>` component. + +```jsx +import { + __experimentalInputControl as InputControl, + __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper, +} from '@wordpress/components'; + +<InputControl + prefix={<InputControlPrefixWrapper>@</InputControlPrefixWrapper>} +/> +``` -- 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 `<InputControlSuffixWrapper>` component. + +```jsx +import { + __experimentalInputControl as InputControl, + __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper, +} from '@wordpress/components'; + +<InputControl + suffix={<InputControlSuffixWrapper>%</InputControlSuffixWrapper>} +/> +``` + + - 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 00000000000000..0e74d71d309e10 --- /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 075ae1268e3c72..66116576361623 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 ( * <TreeSelect * __nextHasNoMarginBottom + * __next40pxDefaultSize * label="Parent page" * noOptionLabel="No parent page" * onChange={ ( newPage ) => 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 ( <ContextSystemProvider value={ CONTEXT_VALUE }> <SelectControl diff --git a/packages/components/src/tree-select/stories/index.story.tsx b/packages/components/src/tree-select/stories/index.story.tsx index b43245e5e16213..0ef8f44b790db0 100644 --- a/packages/components/src/tree-select/stories/index.story.tsx +++ b/packages/components/src/tree-select/stories/index.story.tsx @@ -50,6 +50,7 @@ const TreeSelectWithState: StoryFn< typeof TreeSelect > = ( 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 da90ece3a658e8..59e8e173fab02f 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. */ diff --git a/packages/compose/src/hooks/use-focus-return/index.js b/packages/compose/src/hooks/use-focus-return/index.js index 2cd93b279cd318..36dc7560669652 100644 --- a/packages/compose/src/hooks/use-focus-return/index.js +++ b/packages/compose/src/hooks/use-focus-return/index.js @@ -48,7 +48,13 @@ function useFocusReturn( onFocusReturn ) { return; } - focusedBeforeMount.current = node.ownerDocument.activeElement; + const activeDocument = + node.ownerDocument.activeElement instanceof + window.HTMLIFrameElement + ? node.ownerDocument.activeElement.contentDocument + : node.ownerDocument; + + focusedBeforeMount.current = activeDocument?.activeElement ?? null; } else if ( focusedBeforeMount.current ) { const isFocused = ref.current?.contains( ref.current?.ownerDocument.activeElement diff --git a/packages/core-data/src/fetch/__experimental-fetch-url-data.js b/packages/core-data/src/fetch/__experimental-fetch-url-data.js index effb0566691dfe..003cc0ebf74ebb 100644 --- a/packages/core-data/src/fetch/__experimental-fetch-url-data.js +++ b/packages/core-data/src/fetch/__experimental-fetch-url-data.js @@ -29,7 +29,7 @@ const CACHE = new Map(); * * @async * @param {string} url the URL to request details from. - * @param {Object?} options any options to pass to the underlying fetch. + * @param {?Object} options any options to pass to the underlying fetch. * @example * ```js * import { __experimentalFetchUrlData as fetchUrlData } from '@wordpress/core-data'; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a35403c0493460..4f101035b10130 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -226,7 +226,7 @@ export const getEditedEntityRecord = forwardResolver( 'getEntityRecord' ); * * @param {string} kind Entity kind. * @param {string} name Entity name. - * @param {Object?} query Query Object. If requesting specific fields, fields + * @param {?Object} query Query Object. If requesting specific fields, fields * must always include the ID. */ export const getEntityRecords = diff --git a/packages/create-block/CHANGELOG.md b/packages/create-block/CHANGELOG.md index 73522a9be0726d..e109e36ccbd798 100644 --- a/packages/create-block/CHANGELOG.md +++ b/packages/create-block/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Enhancement + +- Add support for custom `textdomain` property for the scaffolded block ([#57197](https://github.com/WordPress/gutenberg/pull/57197)). + +### Internal + +- Refactored the code to use new API introduced together with `@inquirer/prompts` instead of legacy `inquirer` package ([#67877](https://github.com/WordPress/gutenberg/pull/67877)). + ## 4.57.0 (2024-12-11) ### Internal diff --git a/packages/create-block/lib/check-system-requirements.js b/packages/create-block/lib/check-system-requirements.js index 4a88d167d437c7..152931bc191410 100644 --- a/packages/create-block/lib/check-system-requirements.js +++ b/packages/create-block/lib/check-system-requirements.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); const checkSync = require( 'check-node-version' ); const tools = require( 'check-node-version/tools' ); const { promisify } = require( 'util' ); @@ -34,14 +34,10 @@ async function checkSystemRequirements( engines ) { log.error( 'The program may not complete correctly if you continue.' ); log.info( '' ); - const { yesContinue } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesContinue', - message: 'Are you sure you want to continue anyway?', - default: false, - }, - ] ); + const yesContinue = await confirm( { + message: 'Are you sure you want to continue anyway?', + default: false, + } ); if ( ! yesContinue ) { log.error( 'Cancelled.' ); diff --git a/packages/create-block/lib/index.js b/packages/create-block/lib/index.js index da08bcd4ab1dc7..ccc2e91b106e20 100644 --- a/packages/create-block/lib/index.js +++ b/packages/create-block/lib/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const inquirer = require( 'inquirer' ); +const { confirm, select } = require( '@inquirer/prompts' ); const { capitalCase } = require( 'change-case' ); const program = require( 'commander' ); @@ -14,9 +14,9 @@ const log = require( './log' ); const { engines, version } = require( '../package.json' ); const scaffold = require( './scaffold' ); const { - getPluginTemplate, getDefaultValues, - getPrompts, + getProjectTemplate, + runPrompts, } = require( './templates' ); const commandName = `wp-create-block`; @@ -79,11 +79,13 @@ program targetDir, } ) => { - await checkSystemRequirements( engines ); try { - const pluginTemplate = await getPluginTemplate( templateName ); + await checkSystemRequirements( engines ); + + const projectTemplate = + await getProjectTemplate( templateName ); const availableVariants = Object.keys( - pluginTemplate.variants + projectTemplate.variants ); if ( variant && ! availableVariants.includes( variant ) ) { if ( ! availableVariants.length ) { @@ -113,7 +115,7 @@ program if ( slug ) { const defaultValues = getDefaultValues( - pluginTemplate, + projectTemplate, variant ); const answers = { @@ -123,7 +125,7 @@ program title: capitalCase( slug ), ...optionsValues, }; - await scaffold( pluginTemplate, answers ); + await scaffold( projectTemplate, answers ); } else { log.info( '' ); log.info( @@ -133,25 +135,22 @@ program ); if ( ! variant && availableVariants.length > 1 ) { - const result = await inquirer.prompt( { - type: 'list', - name: 'variant', + variant = await select( { message: 'The template variant to use for this block:', - choices: availableVariants, + choices: availableVariants.map( ( value ) => ( { + value, + } ) ), } ); - variant = result.variant; } const defaultValues = getDefaultValues( - pluginTemplate, + projectTemplate, variant ); - const filterOptionsProvided = ( { name } ) => - ! Object.keys( optionsValues ).includes( name ); - const blockPrompts = getPrompts( - pluginTemplate, + const blockAnswers = await runPrompts( + projectTemplate, [ 'slug', 'namespace', @@ -159,45 +158,36 @@ program 'description', 'dashicon', 'category', - ], - variant - ).filter( filterOptionsProvided ); - const blockAnswers = await inquirer.prompt( blockPrompts ); - - const pluginAnswers = plugin - ? await inquirer - .prompt( { - type: 'confirm', - name: 'configurePlugin', - message: - 'Do you want to customize the WordPress plugin?', - default: false, - } ) - .then( async ( { configurePlugin } ) => { - if ( ! configurePlugin ) { - return {}; - } + ! plugin && 'textdomain', + ].filter( Boolean ), + variant, + optionsValues + ); - const pluginPrompts = getPrompts( - pluginTemplate, - [ - 'pluginURI', - 'version', - 'author', - 'license', - 'licenseURI', - 'domainPath', - 'updateURI', - ], - variant - ).filter( filterOptionsProvided ); - const result = - await inquirer.prompt( pluginPrompts ); - return result; - } ) - : {}; + const pluginAnswers = + plugin && + ( await confirm( { + message: + 'Do you want to customize the WordPress plugin?', + default: false, + } ) ) + ? await runPrompts( + projectTemplate, + [ + 'pluginURI', + 'version', + 'author', + 'license', + 'licenseURI', + 'domainPath', + 'updateURI', + ], + variant, + optionsValues + ) + : {}; - await scaffold( pluginTemplate, { + await scaffold( projectTemplate, { ...defaultValues, ...optionsValues, variant, @@ -209,6 +199,9 @@ program if ( error instanceof CLIError ) { log.error( error.message ); process.exit( 1 ); + } else if ( error.name === 'ExitPromptError' ) { + log.info( 'Cancelled.' ); + process.exit( 1 ); } else { throw error; } diff --git a/packages/create-block/lib/prompts.js b/packages/create-block/lib/prompts.js index 12da9f892b80e6..88bdaf22635d36 100644 --- a/packages/create-block/lib/prompts.js +++ b/packages/create-block/lib/prompts.js @@ -11,7 +11,6 @@ const upperFirst = ( [ firstLetter, ...rest ] ) => // Block metadata. const slug = { type: 'input', - name: 'slug', message: 'The block slug used for identification (also the output folder name):', validate( input ) { @@ -25,7 +24,6 @@ const slug = { const namespace = { type: 'input', - name: 'namespace', message: 'The internal namespace for the block name (something unique for your products):', validate( input ) { @@ -39,25 +37,22 @@ const namespace = { const title = { type: 'input', - name: 'title', message: 'The display title for your block:', - filter( input ) { + transformer( input ) { return input && upperFirst( input ); }, }; const description = { type: 'input', - name: 'description', message: 'The short description for your block (optional):', - filter( input ) { + transformer( input ) { return input && upperFirst( input ); }, }; const dashicon = { type: 'input', - name: 'dashicon', message: 'The dashicon to make it easier to identify your block (optional):', validate( input ) { @@ -67,29 +62,41 @@ const dashicon = { return true; }, - filter( input ) { + transformer( input ) { return input && input.replace( /dashicon(s)?-/, '' ); }, }; const category = { - type: 'list', - name: 'category', + type: 'select', message: 'The category name to help users browse and discover your block:', - choices: [ 'text', 'media', 'design', 'widgets', 'theme', 'embed' ], + choices: [ 'text', 'media', 'design', 'widgets', 'theme', 'embed' ].map( + ( value ) => ( { value } ) + ), +}; + +const textdomain = { + type: 'input', + message: + 'The text domain used to make strings translatable in the block (optional):', + validate( input ) { + if ( input.length && ! /^[a-z][a-z0-9\-]*$/.test( input ) ) { + return 'Invalid text domain specified. Text domain can contain only lowercase alphanumeric characters or dashes, and start with a letter.'; + } + + return true; + }, }; // Plugin header fields. const pluginURI = { type: 'input', - name: 'pluginURI', message: 'The home page of the plugin (optional). Unique URL outside of WordPress.org:', }; const version = { type: 'input', - name: 'version', message: 'The current version number of the plugin:', validate( input ) { // Regular expression was copied from https://semver.org. @@ -105,32 +112,27 @@ const version = { const author = { type: 'input', - name: 'author', message: 'The name of the plugin author (optional). Multiple authors may be listed using commas:', }; const license = { type: 'input', - name: 'license', message: 'The short name of the plugin’s license (optional):', }; const licenseURI = { type: 'input', - name: 'licenseURI', message: 'A link to the full text of the license (optional):', }; const domainPath = { type: 'input', - name: 'domainPath', message: 'A custom domain path for the translations (optional):', }; const updateURI = { type: 'input', - name: 'updateURI', message: 'A custom update URI for the plugin (optional):', }; @@ -141,6 +143,7 @@ module.exports = { description, dashicon, category, + textdomain, pluginURI, version, author, diff --git a/packages/create-block/lib/scaffold.js b/packages/create-block/lib/scaffold.js index 73b9f549908867..bc7cb3b8bfcd32 100644 --- a/packages/create-block/lib/scaffold.js +++ b/packages/create-block/lib/scaffold.js @@ -26,6 +26,7 @@ module.exports = async ( description, dashicon, category, + textdomain, attributes, supports, author, @@ -95,7 +96,7 @@ module.exports = async ( customPackageJSON, customBlockJSON, example, - textdomain: slug, + textdomain: textdomain || slug, rootDirectory, } ); diff --git a/packages/create-block/lib/templates.js b/packages/create-block/lib/templates.js index 4e70ee66fd3a40..db78ee80aa429a 100644 --- a/packages/create-block/lib/templates.js +++ b/packages/create-block/lib/templates.js @@ -1,6 +1,7 @@ /** * External dependencies */ +const inquirer = require( '@inquirer/prompts' ); const { command } = require( 'execa' ); const glob = require( 'fast-glob' ); const { resolve } = require( 'path' ); @@ -157,7 +158,7 @@ const configToTemplate = async ( { }; }; -const getPluginTemplate = async ( templateName ) => { +const getProjectTemplate = async ( templateName ) => { if ( predefinedPluginTemplates[ templateName ] ) { return await configToTemplate( predefinedPluginTemplates[ templateName ] @@ -224,12 +225,13 @@ const getPluginTemplate = async ( templateName ) => { } }; -const getDefaultValues = ( pluginTemplate, variant ) => { +const getDefaultValues = ( projectTemplate, variant ) => { return { $schema: 'https://schemas.wp.org/trunk/block.json', apiVersion: 3, namespace: 'create-block', category: 'widgets', + textdomain: '', author: 'The WordPress Contributors', license: 'GPL-2.0-or-later', licenseURI: 'https://www.gnu.org/licenses/gpl-2.0.html', @@ -243,20 +245,33 @@ const getDefaultValues = ( pluginTemplate, variant ) => { editorStyle: 'file:./index.css', style: 'file:./style-index.css', transformer: ( view ) => view, - ...pluginTemplate.defaultValues, - ...pluginTemplate.variants?.[ variant ], - variantVars: getVariantVars( pluginTemplate.variants, variant ), + ...projectTemplate.defaultValues, + ...projectTemplate.variants?.[ variant ], + variantVars: getVariantVars( projectTemplate.variants, variant ), }; }; -const getPrompts = ( pluginTemplate, keys, variant ) => { - const defaultValues = getDefaultValues( pluginTemplate, variant ); - return keys.map( ( promptName ) => { - return { - ...prompts[ promptName ], +const runPrompts = async ( + projectTemplate, + promptNames, + variant, + optionsValues +) => { + const defaultValues = getDefaultValues( projectTemplate, variant ); + const result = {}; + for ( const promptName of promptNames ) { + if ( Object.keys( optionsValues ).includes( promptName ) ) { + continue; + } + + const { type, ...config } = prompts[ promptName ]; + result[ promptName ] = await inquirer[ type ]( { + ...config, default: defaultValues[ promptName ], - }; - } ); + } ); + } + + return result; }; const getVariantVars = ( variants, variant ) => { @@ -277,7 +292,7 @@ const getVariantVars = ( variants, variant ) => { }; module.exports = { - getPluginTemplate, getDefaultValues, - getPrompts, + getProjectTemplate, + runPrompts, }; diff --git a/packages/create-block/package.json b/packages/create-block/package.json index 375fee43ba1f73..728cf04b3f4437 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -31,6 +31,7 @@ "wp-create-block": "./index.js" }, "dependencies": { + "@inquirer/prompts": "^7.2.0", "@wordpress/lazy-import": "*", "chalk": "^4.0.0", "change-case": "^4.1.2", @@ -38,7 +39,6 @@ "commander": "^9.2.0", "execa": "^4.0.2", "fast-glob": "^3.2.7", - "inquirer": "^7.1.0", "make-dir": "^3.0.0", "mustache": "^4.0.0", "npm-package-arg": "^8.1.5", diff --git a/packages/customize-widgets/src/components/header/style.scss b/packages/customize-widgets/src/components/header/style.scss index 5c3f37a0bf0d42..73789282108af6 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); + } } } diff --git a/packages/data/README.md b/packages/data/README.md index 67c01af24bde32..00105722bd04fb 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -392,7 +392,7 @@ Creates a new store registry, given an optional object of initial store configur _Parameters_ - _storeConfigs_ `Object`: Initial store configurations. -- _parent_ `Object?`: Parent registry. +- _parent_ `?Object`: Parent registry. _Returns_ diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 3e7a8fdd8b5a07..8db8bfbbbb702d 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -49,7 +49,7 @@ function getStoreName( storeNameOrDescriptor ) { * configurations. * * @param {Object} storeConfigs Initial store configurations. - * @param {Object?} parent Parent registry. + * @param {?Object} parent Parent registry. * * @return {WPDataRegistry} Data registry. */ diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 0468a277ba292e..965d98e80d6aea 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,24 +2,32 @@ ## Unreleased +### Bug Fixes + +- Fixed commonjs export ([#67962](https://github.com/WordPress/gutenberg/pull/67962)) + +### Features + +- Add support for hierarchical visualization of data. `DataViews` gets a new prop `getItemLevel` that should return the hierarchical level of the item. The view can use `view.showLevels` to display the levels. It's up to the consumer data source to prepare this information. + ## 4.10.0 (2024-12-11) -## Breaking Changes +### 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 +### Internal - Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). - Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)). diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 6f74a13d8f197a..4cce66a6ae6b26 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -2,8 +2,8 @@ The DataViews package offers two React components and a few utilities to work with a list of data: -- `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. +- `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 @@ -23,13 +23,15 @@ npm install @wordpress/dataviews --save 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") +![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png 'DataViews flow') Example: ```jsx const Example = () => { - const onChangeView = () => { /* React to user changes. */ } + const onChangeView = () => { + /* React to user changes. */ + }; return ( <DataViews @@ -45,7 +47,6 @@ const Example = () => { }; ``` - ### Properties #### `data`: `Object[]` @@ -87,6 +88,19 @@ Example: } ``` +#### `getItemLevel`: `function` + +A function that receives an item and returns its hierarchical level. It's optional, but this property must be passed for DataViews to display the hierarchical levels of the data if `view.showLevels` is true. + +Example: + +```js +// Example implementation +{ + getItemLevel={ ( item ) => item.level } +} +``` + #### `fields`: `Object[]` 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. @@ -185,21 +199,23 @@ Properties: - `field`: the field used for sorting the dataset. - `direction`: the direction to use for sorting, one of `asc` or `desc`. + - `titleField`: The id of the field representing the title of the record. - `mediaField`: The id of the field representing the media of the record. - `descriptionField`: The id of the field representing the description of the record. - `showTitle`: Whether the title should be shown in the UI. `true` by default. - `showMedia`: Whether the media should be shown in the UI. `true` by default. - `showDescription`: Whether the description should be shown in the UI. `true` by default. +- `showLevels`: Whether to display the hierarchical levels for the data. `false` by default. See related `getItemLevel` DataView prop. - `fields`: a list of remaining 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` | Table | Grid | List | -| --------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---- | -| `badgeFields`: a list of field's `id` to render without label and styled as badges. | | ✓ | | -| `styles`: additional `width`, `maxWidth`, `minWidth` styles for each field column. | ✓ | | | +| Properties of `layout` | Table | Grid | List | +| ----------------------------------------------------------------------------------- | ----- | ---- | ---- | +| `badgeFields`: a list of field's `id` to render without label and styled as badges. | | ✓ | | +| `styles`: additional `width`, `maxWidth`, `minWidth` styles for each field column. | ✓ | | | #### `onChangeView`: `function` @@ -302,8 +318,8 @@ const actions = [ RenderModal: ( { items, closeModal, onActionPerformed } ) => ( <div> <p>Are you sure you want to delete { items.length } item(s)?</p> - <Button - variant="primary" + <Button + variant="primary" onClick={() => { console.log( 'Deleting items:', items ); onActionPerformed(); @@ -348,7 +364,7 @@ const defaultLayouts = { }, grid: { showMedia: true, - } + }, }; ``` @@ -366,11 +382,11 @@ Callback that signals the user selected one of more items. It receives the list If `selection` and `onChangeSelection` are provided, the `DataViews` component behaves like a controlled component. Otherwise, it behaves like an uncontrolled component. -### `isItemClickable`: `function` +#### `isItemClickable`: `function` A function that determines if a media field or a primary field is clickable. It receives an item as an argument and returns a boolean value indicating whether the item can be clicked. -### `onClickItem`: `function` +#### `onClickItem`: `function` A callback function that is triggered when a user clicks on a media field or primary field. This function is currently implemented only in the `grid` and `list` views. @@ -395,8 +411,8 @@ const Example = () => { form={ form } onChange={ onChange } /> - ) -} + ); +}; ``` ### Properties @@ -439,8 +455,30 @@ const fields = [ #### `form`: `Object[]` -- `type`: either `regular` or `panel`. -- `fields`: a list of fields ids that should be rendered. +- `type`: either `regular` or `panel`. +- `labelPosition`: either `side`, `top`, or `none`. +- `fields`: a list of fields ids that should be rendered. Field ids can also be defined as an object and allow you to define a `layout`, `labelPosition` or `children` if displaying combined fields. See "Form Field API" for a description of every property. + +Example: + +```js +const form = { + type: 'panel', + fields: [ + 'title', + 'data', + { + id: 'status', + label: 'Status & Visibility', + children: [ 'status', 'password' ], + }, + { + id: 'featured_media', + layout: 'regular', + }, + ], +}; +``` #### `onChange`: `function` @@ -471,10 +509,10 @@ const onChange = ( edits ) => { return ( <DataForm - data={data} - fields={fields} - form={form} - onChange={onChange} + data={ data } + fields={ fields } + form={ form } + onChange={ onChange } /> ); ``` @@ -487,16 +525,16 @@ Utility to apply the view config (filters, search, sorting, and pagination) to a 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. +- `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. +- `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` @@ -504,9 +542,9 @@ Utility is used to determine whether or not the given item's value is valid acco 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. +- `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). @@ -516,17 +554,17 @@ Returns a boolean indicating if the item is valid (true) or not (false). The unique identifier of the action. -- Type: `string` -- Required -- Example: `move-to-trash` +- Type: `string` +- Required +- Example: `move-to-trash` -### `label` +### `label` The user facing description of the action. -- Type: `string | function` -- Required -- Example: +- Type: `string | function` +- Required +- Example: ```js { @@ -538,7 +576,7 @@ or ```js { - label: ( items ) => items.length > 1 ? 'Delete items' : 'Delete item' + label: ( items ) => ( items.length > 1 ? 'Delete items' : 'Delete item' ); } ``` @@ -546,27 +584,27 @@ or Whether the action should be displayed inline (primary) or only displayed in the "More actions" menu (secondary). -- Type: `boolean` -- Optional +- Type: `boolean` +- Optional ### `icon` Icon to show for primary actions. -- Type: SVG element -- Required for primary actions, optional for secondary actions. +- Type: SVG element +- Required for primary actions, optional for secondary actions. ### `isEligible` Function that determines whether the action can be performed for a given record. -- Type: `function` -- Optional. If not present, action is considered eligible for all items. -- Example: +- Type: `function` +- Optional. If not present, action is considered eligible for all items. +- Example: ```js { - isEligible: ( item ) => item.status === 'published' + isEligible: ( item ) => item.status === 'published'; } ``` @@ -574,47 +612,47 @@ Function that determines whether the action can be performed for a given record. Whether the action can delete data, in which case the UI communicates it via a red color. -- Type: `boolean` -- Optional +- Type: `boolean` +- Optional ### `supportsBulk` Whether the action can operate over multiple items at once. -- Type: `boolean` -- Optional -- Default: `false` +- Type: `boolean` +- Optional +- Default: `false` ### `disabled` Whether the action is disabled. -- Type: `boolean` -- Optional -- Default: `false` +- Type: `boolean` +- Optional +- Default: `false` ### `context` Where this action would be visible. -- Type: `string` -- Optional -- One of: `list`, `single` +- Type: `string` +- Optional +- One of: `list`, `single` ### `callback` Function that performs the required action. -- Type: `function` -- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored -- Example: +- Type: `function` +- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored +- Example: ```js { callback: ( items, { onActionPerformed } ) => { // Perform action. onActionPerformed?.( items ); - } + }; } ``` @@ -622,9 +660,9 @@ Function that performs the required action. Component to render UI in a modal for the action. -- Type: `ReactElement` -- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored. -- Example: +- Type: `ReactElement` +- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored. +- Example: ```jsx { @@ -648,7 +686,7 @@ Component to render UI in a modal for the action. </HStack> </form> ); - } + }; } ``` @@ -656,17 +694,16 @@ Component to render UI in a modal for the action. Controls visibility of the modal's header when using `RenderModal`. -- Type: `boolean` -- Optional -- When false and using `RenderModal`, the action's label is used in modal header +- Type: `boolean` +- Optional +- When false and using `RenderModal`, the action's label is used in modal header ### `modalHeader` The header text to show in the modal. -- Type: `string` -- Optional - +- Type: `string` +- Optional ## Fields API @@ -674,13 +711,15 @@ The header text to show in the modal. The unique identifier of the field. -- Type: `string`. -- Required. +- Type: `string`. +- Required. Example: ```js -{ id: 'field_id' } +{ + id: 'field_id'; +} ``` ### `type` @@ -689,44 +728,50 @@ Field type. One of `text`, `integer`, `datetime`. If a field declares a `type`, it gets default implementations for the `sort`, `isValid`, and `Edit` functions if no other values are specified. -- Type: `string`. -- Optional. +- Type: `string`. +- Optional. Example: ```js -{ type: 'text' } +{ + type: 'text'; +} ``` ### `label` The field's name. This will be used across the UI. -- Type: `string`. -- Optional. -- Defaults to the `id` value. +- Type: `string`. +- Optional. +- Defaults to the `id` value. Example: ```js -{ label: 'Title' } +{ + 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. +- Type: React component. +- Optional. +- Defaults to the `label` value. +- Props: none. +- Returns a React element that represents the field's name. Example: ```js { - header: () => { /* Returns a react element. */ } + header: () => { + /* Returns a react element. */ + }; } ``` @@ -734,18 +779,20 @@ Example: React component that returns the value of a field. This value is used to sort or filter the fields. -- Type: React component. -- Optional. -- Defaults to `item[ id ]`. -- Props: - - `item` value to be processed. -- Returns a value that represents the field. +- 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. */ }; + getValue: ( { item } ) => { + /* The field's value. */ + }; } ``` @@ -753,18 +800,20 @@ Example: 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. +- 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. */ } + render: ( { item } ) => { + /* React element to be displayed. */ + }; } ``` @@ -772,26 +821,21 @@ Example: 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. +- 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 - } ) => { + Edit: ( { data, field, onChange, hideLabelFromVision } ) => { const value = field.getValue( { item: data } ); return ( @@ -801,14 +845,14 @@ Example: hideLabelFromVision /> ); - } + }; } ``` ```js // Use one of the core controls. { - Edit: 'radio' + Edit: 'radio'; } ``` @@ -816,7 +860,7 @@ Example: // Edit is optional when field's type is present. // The field will use the default Edit function for text. { - type: 'text' + type: 'text'; } ``` @@ -833,16 +877,16 @@ Example: 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 +- 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: @@ -853,7 +897,7 @@ Example: return direction === 'asc' ? a.localeCompare( b ) : b.localeCompare( a ); - } + }; } ``` @@ -861,7 +905,7 @@ Example: // If field type is provided, // the field gets a default sort function. { - type: 'number' + type: 'number'; } ``` @@ -869,8 +913,10 @@ Example: // 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 */ } + type: 'number'; + sort: ( a, b, direction ) => { + /* Custom sort */ + }; } ``` @@ -878,13 +924,13 @@ Example: 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. +- 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. Example: @@ -893,7 +939,7 @@ Example: { isValid: ( item, context ) => { return !! item; - } + }; } ``` @@ -918,18 +964,20 @@ Example: 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`). +- 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. */ } + isVisible: ( item ) => { + /* Custom implementation. */ + }; } ``` @@ -937,54 +985,60 @@ Example: Boolean indicating if the field is sortable. -- Type: `boolean`. -- Optional. -- Defaults to `true`. +- Type: `boolean`. +- Optional. +- Defaults to `true`. Example: ```js -{ enableSorting: true } +{ + enableSorting: true; +} ``` ### `enableHiding` Boolean indicating if the field can be hidden. -- Type: `boolean`. -- Optional. -- Defaults to `true`. +- Type: `boolean`. +- Optional. +- Defaults to `true`. Example: ```js -{ enableHiding: true } +{ + enableHiding: true; +} ``` ### `enableGlobalSearch` Boolean indicating if the field is searchable. -- Type: `boolean`. -- Optional. -- Defaults to `false`. +- Type: `boolean`. +- Optional. +- Defaults to `false`. Example: ```js -{ enableGlobalSearch: true } +{ + 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 also use these values. (See `Edit` field property.) -- Type: `array` of objects. -- Optional. -- Each object can have the following properties: - - `value`: the value to match against the field's value. (Required) - - `label`: the name to display to users. (Required) - - `description`: optional, a longer description of the item. +- Type: `array` of objects. +- Optional. +- Each object can have the following properties: + - `value`: the value to match against the field's value. (Required) + - `label`: the name to display to users. (Required) + - `description`: optional, a longer description of the item. Example: @@ -995,7 +1049,7 @@ Example: { value: '2', label: 'Product B' }, { value: '3', label: 'Product C' }, { value: '4', label: 'Product D' }, - ] + ]; } ``` @@ -1003,11 +1057,11 @@ Example: Configuration of the filters. -- Type: `object`. -- Optional. -- Properties: - - `operators`: the list of operators supported by the field. See "operators" below. A filter will support the `isAny` and `isNone` multi-selection operators by default. - - `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. +- Type: `object`. +- Optional. +- Properties: + - `operators`: the list of operators supported by the field. See "operators" below. A filter will support the `isAny` and `isNone` multi-selection operators by default. + - `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: @@ -1028,7 +1082,7 @@ Example: // Set a filter as primary. { filterBy: { - isPrimary: true + isPrimary: true; } } ``` @@ -1037,7 +1091,7 @@ Example: // Configure a filter as single-selection. { filterBy: { - operators: [ `is`, `isNot` ] + operators: [ `is`, `isNot` ]; } } ``` @@ -1046,11 +1100,91 @@ Example: // Configure a filter as multi-selection with all the options. { filterBy: { - operators: [ `isAny`, `isNone`, `isAll`, `isNotAll` ] + operators: [ `isAny`, `isNone`, `isAll`, `isNotAll` ]; } } ``` +## Form Field API + +### `id` + +The unique identifier of the field. + +- Type: `string`. +- Required. + +Example: + +```js +{ + id: 'field_id'; +} +``` + +### `layout` + +The same as the `form.type`, either `regular` or `panel` only for the individual field. It defaults to `form.type`. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + layout: 'regular' +} +``` + +### `labelPosition` + +The same as the `form.labelPosition`, either `side`, `top`, or `none` for the individual field. It defaults to `form.labelPosition`. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + labelPosition: 'none' +} +``` + +### `label` + +The label used when displaying a combined field, this requires the use of `children` as well. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + label: 'Combined Field', + children: [ 'field1', 'field2' ] +} +``` + +### `children` + +Groups a set of fields defined within children. For example if you want to display multiple fields within the Panel dropdown you can use children ( see example ). + +- Type: `Array< string | FormField >`. + +Example: + +```js +{ + id: 'status', + layout: 'panel', + label: 'Combined Field', + children: [ 'field1', 'field2' ], +} +``` + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index c307085bbea078..7f6d96745acab1 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", diff --git a/packages/dataviews/src/components/dataviews-context/index.ts b/packages/dataviews/src/components/dataviews-context/index.ts index 4bef3ecdbcbb4a..992048f9097064 100644 --- a/packages/dataviews/src/components/dataviews-context/index.ts +++ b/packages/dataviews/src/components/dataviews-context/index.ts @@ -26,8 +26,10 @@ type DataViewsContextType< Item > = { openedFilter: string | null; setOpenedFilter: ( openedFilter: string | null ) => void; getItemId: ( item: Item ) => string; + getItemLevel?: ( item: Item ) => number; onClickItem?: ( item: Item ) => void; isItemClickable: ( item: Item ) => boolean; + containerWidth: number; }; const DataViewsContext = createContext< DataViewsContextType< any > >( { @@ -45,6 +47,7 @@ const DataViewsContext = createContext< DataViewsContextType< any > >( { openedFilter: null, getItemId: ( item ) => item.id, isItemClickable: () => true, + containerWidth: 0, } ); export default DataViewsContext; diff --git a/packages/dataviews/src/components/dataviews-filters/add-filter.tsx b/packages/dataviews/src/components/dataviews-filters/add-filter.tsx index 94aebb71ea5874..3921fd88eaaa29 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 ( - <Menu trigger={ trigger }> - { inactiveFilters.map( ( filter ) => { - return ( - <Menu.Item - key={ filter.field } - onClick={ () => { - setOpenedFilter( filter.field ); - onChangeView( { - ...view, - page: 1, - filters: [ - ...( view.filters || [] ), - { - field: filter.field, - value: undefined, - operator: filter.operators[ 0 ], - }, - ], - } ); - } } - > - <Menu.ItemLabel>{ filter.name }</Menu.ItemLabel> - </Menu.Item> - ); - } ) } + <Menu> + <Menu.TriggerButton { ...triggerProps } /> + <Menu.Popover> + { inactiveFilters.map( ( filter ) => { + return ( + <Menu.Item + key={ filter.field } + onClick={ () => { + setOpenedFilter( filter.field ); + onChangeView( { + ...view, + page: 1, + filters: [ + ...( view.filters || [] ), + { + field: filter.field, + value: undefined, + operator: filter.operators[ 0 ], + }, + ], + } ); + } } + > + <Menu.ItemLabel>{ filter.name }</Menu.ItemLabel> + </Menu.Item> + ); + } ) } + </Menu.Popover> </Menu> ); } @@ -78,18 +81,19 @@ function AddFilter( const inactiveFilters = filters.filter( ( filter ) => ! filter.isVisible ); return ( <AddFilterMenu - trigger={ - <Button - accessibleWhenDisabled - size="compact" - className="dataviews-filters-button" - variant="tertiary" - disabled={ ! inactiveFilters.length } - ref={ ref } - > - { __( 'Add filter' ) } - </Button> - } + triggerProps={ { + render: ( + <Button + accessibleWhenDisabled + size="compact" + className="dataviews-filters-button" + variant="tertiary" + disabled={ ! inactiveFilters.length } + ref={ ref } + /> + ), + 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 440df4f17310d6..180e17d4b7f0cc 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 } } /> ) : ( <FilterVisibilityToggle diff --git a/packages/dataviews/src/components/dataviews-footer/style.scss b/packages/dataviews/src/components/dataviews-footer/style.scss index cdb1359ccee393..a5cd4dcac9ca02 100644 --- a/packages/dataviews/src/components/dataviews-footer/style.scss +++ b/packages/dataviews/src/components/dataviews-footer/style.scss @@ -11,15 +11,12 @@ z-index: z-index(".dataviews-footer"); } - -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews-footer { padding: $grid-unit-15 $grid-unit-30; } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 560px) { .dataviews-footer { flex-direction: column !important; diff --git a/packages/dataviews/src/components/dataviews-item-actions/index.tsx b/packages/dataviews/src/components/dataviews-item-actions/index.tsx index abe63e27a15b3b..f849eca7c783f0 100644 --- a/packages/dataviews/src/components/dataviews-item-actions/index.tsx +++ b/packages/dataviews/src/components/dataviews-item-actions/index.tsx @@ -229,25 +229,27 @@ function CompactItemActions< Item >( { ); return ( <> - <Menu - trigger={ - <Button - size={ isSmall ? 'small' : 'compact' } - icon={ moreVertical } - label={ __( 'Actions' ) } - accessibleWhenDisabled - disabled={ ! actions.length } - className="dataviews-all-actions-button" - /> - } - placement="bottom-end" - > - <ActionsMenuGroup - actions={ actions } - item={ item } - registry={ registry } - setActiveModalAction={ setActiveModalAction } + <Menu placement="bottom-end"> + <Menu.TriggerButton + render={ + <Button + size={ isSmall ? 'small' : 'compact' } + icon={ moreVertical } + label={ __( 'Actions' ) } + accessibleWhenDisabled + disabled={ ! actions.length } + className="dataviews-all-actions-button" + /> + } /> + <Menu.Popover> + <ActionsMenuGroup + actions={ actions } + item={ item } + registry={ registry } + setActiveModalAction={ setActiveModalAction } + /> + </Menu.Popover> </Menu> { !! activeModalAction && ( <ActionModal diff --git a/packages/dataviews/src/components/dataviews-layout/index.tsx b/packages/dataviews/src/components/dataviews-layout/index.tsx index ebc251eae36a7a..d30b1d39c6524d 100644 --- a/packages/dataviews/src/components/dataviews-layout/index.tsx +++ b/packages/dataviews/src/components/dataviews-layout/index.tsx @@ -21,6 +21,7 @@ export default function DataViewsLayout() { data, fields, getItemId, + getItemLevel, isLoading, view, onChangeView, @@ -40,6 +41,7 @@ export default function DataViewsLayout() { data={ data } fields={ fields } getItemId={ getItemId } + getItemLevel={ getItemLevel } isLoading={ isLoading } onChangeView={ onChangeView } onChangeSelection={ onChangeSelection } diff --git a/packages/dataviews/src/components/dataviews-view-config/index.tsx b/packages/dataviews/src/components/dataviews-view-config/index.tsx index 3146064a41922b..0b3512714e14a4 100644 --- a/packages/dataviews/src/components/dataviews-view-config/index.tsx +++ b/packages/dataviews/src/components/dataviews-view-config/index.tsx @@ -69,50 +69,57 @@ function ViewTypeMenu( { } const activeView = VIEW_LAYOUTS.find( ( v ) => view.type === v.type ); return ( - <Menu - trigger={ - <Button - size="compact" - icon={ activeView?.icon } - label={ __( 'Layout' ) } - /> - } - > - { availableLayouts.map( ( layout ) => { - const config = VIEW_LAYOUTS.find( ( v ) => v.type === layout ); - if ( ! config ) { - return null; + <Menu> + <Menu.TriggerButton + render={ + <Button + size="compact" + icon={ activeView?.icon } + label={ __( 'Layout' ) } + /> } - return ( - <Menu.RadioItem - key={ layout } - value={ layout } - name="view-actions-available-view" - checked={ layout === view.type } - hideOnClick - onChange={ ( e: ChangeEvent< HTMLInputElement > ) => { - 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' ); - } } - > - <Menu.ItemLabel>{ config.label }</Menu.ItemLabel> - </Menu.RadioItem> - ); - } ) } + /> + <Menu.Popover> + { availableLayouts.map( ( layout ) => { + const config = VIEW_LAYOUTS.find( + ( v ) => v.type === layout + ); + if ( ! config ) { + return null; + } + return ( + <Menu.RadioItem + key={ layout } + value={ layout } + name="view-actions-available-view" + checked={ layout === view.type } + hideOnClick + onChange={ ( + e: ChangeEvent< HTMLInputElement > + ) => { + 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' ); + } } + > + <Menu.ItemLabel>{ config.label }</Menu.ItemLabel> + </Menu.RadioItem> + ); + } ) } + </Menu.Popover> </Menu> ); } @@ -145,6 +152,7 @@ function SortFieldControl() { direction: view?.sort?.direction || 'desc', field: value, }, + showLevels: false, } ); } } /> @@ -187,6 +195,7 @@ function SortDirectionControl() { )?.id || '', }, + showLevels: false, } ); return; } diff --git a/packages/dataviews/src/components/dataviews-view-config/style.scss b/packages/dataviews/src/components/dataviews-view-config/style.scss index 0fd97b916b4aa8..692dddfb7a90b4 100644 --- a/packages/dataviews/src/components/dataviews-view-config/style.scss +++ b/packages/dataviews/src/components/dataviews-view-config/style.scss @@ -43,7 +43,6 @@ display: none; } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 500px) { .dataviews-settings-section.dataviews-settings-section { grid-template-columns: repeat(2, 1fr); diff --git a/packages/dataviews/src/components/dataviews/index.tsx b/packages/dataviews/src/components/dataviews/index.tsx index 99d9b6d684b08c..a0a89488136548 100644 --- a/packages/dataviews/src/components/dataviews/index.tsx +++ b/packages/dataviews/src/components/dataviews/index.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from 'react'; */ import { __experimentalHStack as HStack } from '@wordpress/components'; import { useMemo, useState } from '@wordpress/element'; +import { useResizeObserver } from '@wordpress/compose'; /** * Internal dependencies @@ -47,6 +48,7 @@ type DataViewsProps< Item > = { onClickItem?: ( item: Item ) => void; isItemClickable?: ( item: Item ) => boolean; header?: ReactNode; + getItemLevel?: ( item: Item ) => number; } & ( Item extends ItemWithId ? { getItemId?: ( item: Item ) => string } : { getItemId: ( item: Item ) => string } ); @@ -64,6 +66,7 @@ export default function DataViews< Item >( { actions = EMPTY_ARRAY, data, getItemId = defaultGetItemId, + getItemLevel, isLoading = false, paginationInfo, defaultLayouts, @@ -73,6 +76,15 @@ export default function DataViews< Item >( { isItemClickable = defaultIsItemClickable, header, }: DataViewsProps< Item > ) { + const [ containerWidth, setContainerWidth ] = useState( 0 ); + const containerRef = useResizeObserver( + ( resizeObserverEntries: any ) => { + setContainerWidth( + resizeObserverEntries[ 0 ].borderBoxSize[ 0 ].inlineSize + ); + }, + { box: 'border-box' } + ); const [ selectionState, setSelectionState ] = useState< string[] >( [] ); const isUncontrolled = selectionProperty === undefined || onChangeSelection === undefined; @@ -115,11 +127,13 @@ export default function DataViews< Item >( { openedFilter, setOpenedFilter, getItemId, + getItemLevel, isItemClickable, onClickItem, + containerWidth, } } > - <div className="dataviews-wrapper"> + <div className="dataviews-wrapper" ref={ containerRef }> <HStack alignment="top" justify="space-between" diff --git a/packages/dataviews/src/components/dataviews/style.scss b/packages/dataviews/src/components/dataviews/style.scss index b38447094c99a9..3c85115c06dddf 100644 --- a/packages/dataviews/src/components/dataviews/style.scss +++ b/packages/dataviews/src/components/dataviews/style.scss @@ -33,7 +33,6 @@ @include reduce-motion( "transition" ); } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews__view-actions, .dataviews-filters__container { diff --git a/packages/dataviews/src/dataviews-layouts/grid/index.tsx b/packages/dataviews/src/dataviews-layouts/grid/index.tsx index b1f074c5682993..e8f8a46002ebdf 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/grid/index.tsx @@ -13,6 +13,7 @@ import { Spinner, Flex, FlexItem, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; @@ -20,9 +21,13 @@ import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ +import { unlock } from '../../lock-unlock'; 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, @@ -32,6 +37,7 @@ import type { import type { SetSelection } from '../../private-types'; import getClickableItemProps from '../utils/get-clickable-item-props'; import { useUpdatedPreviewSizeOnViewportChange } from './preview-size-picker'; +const { Badge } = unlock( componentsPrivateApis ); interface GridItemProps< Item > { view: ViewGridType; @@ -47,6 +53,7 @@ interface GridItemProps< Item > { descriptionField?: NormalizedField< Item >; regularFields: NormalizedField< Item >[]; badgeFields: NormalizedField< Item >[]; + hasBulkActions: boolean; } function GridItem< Item >( { @@ -63,6 +70,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 +143,7 @@ function GridItem< Item >( { { renderedMediaField } </div> ) } - { showMedia && renderedMediaField && ( + { hasBulkActions && showMedia && renderedMediaField && ( <DataViewsSelectionCheckbox item={ item } selection={ selection } @@ -152,7 +160,9 @@ function GridItem< Item >( { <div { ...clickableTitleItemProps } { ...titleA11yProps }> { renderedTitleField } </div> - <ItemActions item={ item } actions={ actions } isCompact /> + { !! actions?.length && ( + <ItemActions item={ item } actions={ actions } isCompact /> + ) } </HStack> <VStack spacing={ 1 }> { showDescription && descriptionField?.render && ( @@ -168,12 +178,12 @@ function GridItem< Item >( { > { badgeFields.map( ( field ) => { return ( - <FlexItem + <Badge key={ field.id } className="dataviews-view-grid__field-value" > <field.render item={ item } /> - </FlexItem> + </Badge> ); } ) } </HStack> @@ -258,6 +268,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 +303,7 @@ export default function ViewGrid< Item >( { descriptionField={ descriptionField } regularFields={ regularFields } badgeFields={ badgeFields } + hasBulkActions={ hasBulkActions } /> ); } ) } diff --git a/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx b/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx index b48c6422bd6b37..027632090b31b4 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx +++ b/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx @@ -3,7 +3,6 @@ */ import { RangeControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useViewportMatch } from '@wordpress/compose'; import { useMemo, useContext } from '@wordpress/element'; /** @@ -12,7 +11,9 @@ import { useMemo, useContext } from '@wordpress/element'; import DataViewsContext from '../../components/dataviews-context'; import type { ViewGrid } from '../../types'; -const viewportBreaks = { +const viewportBreaks: { + [ key: string ]: { min: number; max: number; default: number }; +} = { xhuge: { min: 3, max: 6, default: 5 }, huge: { min: 2, max: 4, default: 4 }, xlarge: { min: 2, max: 3, default: 3 }, @@ -20,38 +21,35 @@ const viewportBreaks = { mobile: { min: 1, max: 2, default: 2 }, }; -function useViewPortBreakpoint() { - const isXHuge = useViewportMatch( 'xhuge', '>=' ); - const isHuge = useViewportMatch( 'huge', '>=' ); - const isXlarge = useViewportMatch( 'xlarge', '>=' ); - const isLarge = useViewportMatch( 'large', '>=' ); - const isMobile = useViewportMatch( 'mobile', '>=' ); +/** + * Breakpoints were adjusted from media queries breakpoints to account for + * the sidebar width. This was done to match the existing styles we had. + */ +const BREAKPOINTS = { + xhuge: 1520, + huge: 1140, + xlarge: 780, + large: 480, + mobile: 0, +}; - if ( isXHuge ) { - return 'xhuge'; - } - if ( isHuge ) { - return 'huge'; - } - if ( isXlarge ) { - return 'xlarge'; - } - if ( isLarge ) { - return 'large'; - } - if ( isMobile ) { - return 'mobile'; +function useViewPortBreakpoint() { + const containerWidth = useContext( DataViewsContext ).containerWidth; + for ( const [ key, value ] of Object.entries( BREAKPOINTS ) ) { + if ( containerWidth >= value ) { + return key; + } } - return null; + return 'mobile'; } export function useUpdatedPreviewSizeOnViewportChange() { - const viewport = useViewPortBreakpoint(); const view = useContext( DataViewsContext ).view as ViewGrid; + const viewport = useViewPortBreakpoint(); return useMemo( () => { const previewSize = view.layout?.previewSize; let newPreviewSize; - if ( ! viewport || ! previewSize ) { + if ( ! previewSize ) { return; } const breakValues = viewportBreaks[ viewport ]; @@ -69,9 +67,8 @@ export default function PreviewSizePicker() { const viewport = useViewPortBreakpoint(); const context = useContext( DataViewsContext ); const view = context.view as ViewGrid; - const breakValues = viewportBreaks[ viewport || 'mobile' ]; + const breakValues = viewportBreaks[ viewport ]; const previewSizeToUse = view.layout?.previewSize || breakValues.default; - const marks = useMemo( () => Array.from( @@ -84,11 +81,9 @@ export default function PreviewSizePicker() { ), [ breakValues ] ); - - if ( ! viewport ) { + if ( viewport === 'mobile' ) { return null; } - return ( <RangeControl __nextHasNoMarginBottom diff --git a/packages/dataviews/src/dataviews-layouts/grid/style.scss b/packages/dataviews/src/dataviews-layouts/grid/style.scss index 51db297b4025b7..333e6e9a4caf9f 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/style.scss +++ b/packages/dataviews/src/dataviews-layouts/grid/style.scss @@ -3,6 +3,7 @@ grid-template-rows: max-content; padding: 0 $grid-unit-60 $grid-unit-30; transition: padding ease-out 0.1s; + container-type: inline-size; @include reduce-motion("transition"); @@ -111,36 +112,29 @@ &:not(:empty) { padding-bottom: $grid-unit-15; } - - .dataviews-view-grid__field-value { - width: fit-content; - background: $gray-100; - padding: 0 $grid-unit-10; - min-height: $grid-unit-30; - border-radius: $radius-small; - display: flex; - align-items: center; - font-size: 12px; - } } } .dataviews-view-grid.dataviews-view-grid { - grid-template-columns: repeat(1, minmax(0, 1fr)); - - @include break-mobile() { + /** + * Breakpoints were adjusted from media queries breakpoints to account for + * the sidebar width. This was done to match the existing styles we had. + */ + @container (max-width: 480px) { + grid-template-columns: repeat(1, minmax(0, 1fr)); + padding-left: $grid-unit-30; + padding-right: $grid-unit-30; + } + @container (min-width: 480px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - @include break-xlarge() { + @container (min-width: 780px) { grid-template-columns: repeat(3, minmax(0, 1fr)); } - - @include break-huge() { + @container (min-width: 1140px) { grid-template-columns: repeat(4, minmax(0, 1fr)); } - - @include break-xhuge() { + @container (min-width: 1520px) { grid-template-columns: repeat(5, minmax(0, 1fr)); } } @@ -163,14 +157,6 @@ top: $grid-unit-10; } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ -@container (max-width: 430px) { - .dataviews-view-grid { - padding-left: $grid-unit-30; - padding-right: $grid-unit-30; - } -} - .dataviews-view-grid__media--clickable { cursor: pointer; } diff --git a/packages/dataviews/src/dataviews-layouts/list/index.tsx b/packages/dataviews/src/dataviews-layouts/list/index.tsx index fd6cdff6dbcdc6..651834599f8acd 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 && ( <div role="gridcell"> - <Menu - trigger={ - <Composite.Item - id={ generateDropdownTriggerCompositeId( - idPrefix - ) } - render={ - <Button - size="small" - icon={ moreVertical } - label={ __( 'Actions' ) } - accessibleWhenDisabled - disabled={ ! actions.length } - onKeyDown={ onDropdownTriggerKeyDown } - /> - } - /> - } - placement="bottom-end" - > - <ActionsMenuGroup - actions={ eligibleActions } - item={ item } - registry={ registry } - setActiveModalAction={ setActiveModalAction } + <Menu placement="bottom-end"> + <Menu.TriggerButton + render={ + <Composite.Item + id={ generateDropdownTriggerCompositeId( + idPrefix + ) } + render={ + <Button + size="small" + icon={ moreVertical } + label={ __( 'Actions' ) } + accessibleWhenDisabled + disabled={ ! actions.length } + onKeyDown={ + onDropdownTriggerKeyDown + } + /> + } + /> + } /> + <Menu.Popover> + <ActionsMenuGroup + actions={ eligibleActions } + item={ item } + registry={ registry } + setActiveModalAction={ setActiveModalAction } + /> + </Menu.Popover> </Menu> { !! activeModalAction && ( <ActionModal @@ -257,7 +261,7 @@ function ListItem< Item >( { return ( <Composite.Row ref={ itemRef } - render={ <li /> } + render={ <div /> } role="row" className={ clsx( { 'is-selected': isSelected, @@ -482,7 +486,7 @@ export default function ViewList< Item >( props: ViewListProps< Item > ) { return ( <Composite id={ baseId } - render={ <ul /> } + render={ <div /> } 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 82ef269d46964e..e892006faecb00 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; diff --git a/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx b/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx index 763cf83b5c2f93..1d8d22193bbd07 100644 --- a/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx @@ -93,168 +93,172 @@ const _HeaderMenu = forwardRef( function HeaderMenu< Item >( ! field.filterBy?.isPrimary; return ( - <Menu - align="start" - trigger={ - <Button - size="compact" - className="dataviews-view-table-header-button" - ref={ ref } - variant="tertiary" - > - { header } - { view.sort && isSorted && ( - <span aria-hidden="true"> - { sortArrows[ view.sort.direction ] } - </span> - ) } - </Button> - } - style={ { minWidth: '240px' } } - > - <WithMenuSeparators> - { isSortable && ( - <Menu.Group> - { SORTING_DIRECTIONS.map( - ( direction: SortDirection ) => { - const isChecked = - view.sort && - isSorted && - view.sort.direction === direction; + <Menu> + <Menu.TriggerButton + render={ + <Button + size="compact" + className="dataviews-view-table-header-button" + ref={ ref } + variant="tertiary" + /> + } + > + { header } + { view.sort && isSorted && ( + <span aria-hidden="true"> + { sortArrows[ view.sort.direction ] } + </span> + ) } + </Menu.TriggerButton> + <Menu.Popover style={ { minWidth: '240px' } }> + <WithMenuSeparators> + { isSortable && ( + <Menu.Group> + { SORTING_DIRECTIONS.map( + ( direction: SortDirection ) => { + const isChecked = + view.sort && + isSorted && + view.sort.direction === direction; - const value = `${ fieldId }-${ direction }`; + const value = `${ fieldId }-${ direction }`; - return ( - <Menu.RadioItem - key={ value } - // All sorting radio items share the same name, so that - // selecting a sorting option automatically deselects the - // previously selected one, even if it is displayed in - // another submenu. The field and direction are passed via - // the `value` prop. - name="view-table-sorting" - value={ value } - checked={ isChecked } - onChange={ () => { - onChangeView( { - ...view, - sort: { - field: fieldId, - direction, - }, - } ); - } } - > - <Menu.ItemLabel> - { sortLabels[ direction ] } - </Menu.ItemLabel> - </Menu.RadioItem> - ); - } - ) } - </Menu.Group> - ) } - { canAddFilter && ( - <Menu.Group> - <Menu.Item - prefix={ <Icon icon={ funnel } /> } - onClick={ () => { - setOpenedFilter( fieldId ); - onChangeView( { - ...view, - page: 1, - filters: [ - ...( view.filters || [] ), - { - field: fieldId, - value: undefined, - operator: operators[ 0 ], - }, - ], - } ); - } } - > - <Menu.ItemLabel> - { __( 'Add filter' ) } - </Menu.ItemLabel> - </Menu.Item> - </Menu.Group> - ) } - { ( canMove || isHidable ) && field && ( - <Menu.Group> - { canMove && ( + return ( + <Menu.RadioItem + key={ value } + // All sorting radio items share the same name, so that + // selecting a sorting option automatically deselects the + // previously selected one, even if it is displayed in + // another submenu. The field and direction are passed via + // the `value` prop. + name="view-table-sorting" + value={ value } + checked={ isChecked } + onChange={ () => { + onChangeView( { + ...view, + sort: { + field: fieldId, + direction, + }, + showLevels: false, + } ); + } } + > + <Menu.ItemLabel> + { sortLabels[ direction ] } + </Menu.ItemLabel> + </Menu.RadioItem> + ); + } + ) } + </Menu.Group> + ) } + { canAddFilter && ( + <Menu.Group> <Menu.Item - prefix={ <Icon icon={ arrowLeft } /> } - disabled={ index < 1 } + prefix={ <Icon icon={ funnel } /> } 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 ], + }, ], } ); } } > <Menu.ItemLabel> - { __( 'Move left' ) } + { __( 'Add filter' ) } </Menu.ItemLabel> </Menu.Item> - ) } - { canMove && ( - <Menu.Item - prefix={ <Icon icon={ arrowRight } /> } - disabled={ index >= visibleFieldIds.length - 1 } - onClick={ () => { - onChangeView( { - ...view, - fields: [ - ...( visibleFieldIds.slice( - 0, - index - ) ?? [] ), - visibleFieldIds[ index + 1 ], - fieldId, - ...visibleFieldIds.slice( - index + 2 + </Menu.Group> + ) } + { ( canMove || isHidable ) && field && ( + <Menu.Group> + { canMove && ( + <Menu.Item + prefix={ <Icon icon={ arrowLeft } /> } + disabled={ index < 1 } + onClick={ () => { + onChangeView( { + ...view, + fields: [ + ...( visibleFieldIds.slice( + 0, + index - 1 + ) ?? [] ), + fieldId, + visibleFieldIds[ index - 1 ], + ...visibleFieldIds.slice( + index + 1 + ), + ], + } ); + } } + > + <Menu.ItemLabel> + { __( 'Move left' ) } + </Menu.ItemLabel> + </Menu.Item> + ) } + { canMove && ( + <Menu.Item + prefix={ <Icon icon={ arrowRight } /> } + disabled={ + index >= visibleFieldIds.length - 1 + } + onClick={ () => { + onChangeView( { + ...view, + fields: [ + ...( visibleFieldIds.slice( + 0, + index + ) ?? [] ), + visibleFieldIds[ index + 1 ], + fieldId, + ...visibleFieldIds.slice( + index + 2 + ), + ], + } ); + } } + > + <Menu.ItemLabel> + { __( 'Move right' ) } + </Menu.ItemLabel> + </Menu.Item> + ) } + { isHidable && field && ( + <Menu.Item + prefix={ <Icon icon={ unseen } /> } + onClick={ () => { + onHide( field ); + onChangeView( { + ...view, + fields: visibleFieldIds.filter( + ( id ) => id !== fieldId ), - ], - } ); - } } - > - <Menu.ItemLabel> - { __( 'Move right' ) } - </Menu.ItemLabel> - </Menu.Item> - ) } - { isHidable && field && ( - <Menu.Item - prefix={ <Icon icon={ unseen } /> } - onClick={ () => { - onHide( field ); - onChangeView( { - ...view, - fields: visibleFieldIds.filter( - ( id ) => id !== fieldId - ), - } ); - } } - > - <Menu.ItemLabel> - { __( 'Hide column' ) } - </Menu.ItemLabel> - </Menu.Item> - ) } - </Menu.Group> - ) } - </WithMenuSeparators> + } ); + } } + > + <Menu.ItemLabel> + { __( 'Hide column' ) } + </Menu.ItemLabel> + </Menu.Item> + ) } + </Menu.Group> + ) } + </WithMenuSeparators> + </Menu.Popover> </Menu> ); } ); diff --git a/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx b/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx index 6db65be72bdd4c..6ac4057b0973ba 100644 --- a/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx @@ -14,6 +14,7 @@ import getClickableItemProps from '../utils/get-clickable-item-props'; function ColumnPrimary< Item >( { item, + level, titleField, mediaField, descriptionField, @@ -21,6 +22,7 @@ function ColumnPrimary< Item >( { isItemClickable, }: { item: Item; + level?: number; titleField?: NormalizedField< Item >; mediaField?: NormalizedField< Item >; descriptionField?: NormalizedField< Item >; @@ -44,6 +46,11 @@ function ColumnPrimary< Item >( { <VStack spacing={ 0 }> { titleField && ( <div { ...clickableProps }> + { level !== undefined && ( + <span className="dataviews-view-table__level"> + { '—'.repeat( level ) }&nbsp; + </span> + ) } <titleField.render item={ item } /> </div> ) } diff --git a/packages/dataviews/src/dataviews-layouts/table/index.tsx b/packages/dataviews/src/dataviews-layouts/table/index.tsx index b010b3ff154fbb..855e0584563b71 100644 --- a/packages/dataviews/src/dataviews-layouts/table/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/index.tsx @@ -40,6 +40,7 @@ interface TableColumnFieldProps< Item > { interface TableRowProps< Item > { hasBulkActions: boolean; item: Item; + level?: number; actions: Action< Item >[]; fields: NormalizedField< Item >[]; id: string; @@ -75,6 +76,7 @@ function TableColumnField< Item >( { function TableRow< Item >( { hasBulkActions, item, + level, actions, fields, id, @@ -160,6 +162,7 @@ function TableRow< Item >( { <td> <ColumnPrimary item={ item } + level={ level } titleField={ showTitle ? titleField : undefined } mediaField={ showMedia ? mediaField : undefined } descriptionField={ @@ -210,6 +213,7 @@ function ViewTable< Item >( { data, fields, getItemId, + getItemLevel, isLoading = false, onChangeView, onChangeSelection, @@ -375,6 +379,12 @@ function ViewTable< Item >( { <TableRow key={ getItemId( item ) } item={ item } + level={ + view.showLevels && + typeof getItemLevel === 'function' + ? getItemLevel( item ) + : undefined + } hasBulkActions={ hasBulkActions } actions={ actions } fields={ fields } diff --git a/packages/dataviews/src/dataviews-layouts/table/style.scss b/packages/dataviews/src/dataviews-layouts/table/style.scss index 5a713dd428c127..5a4ac01b566f74 100644 --- a/packages/dataviews/src/dataviews-layouts/table/style.scss +++ b/packages/dataviews/src/dataviews-layouts/table/style.scss @@ -203,7 +203,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews-view-table tr td:first-child, .dataviews-view-table tr th:first-child { diff --git a/packages/dataviews/src/test/dataform.tsx b/packages/dataviews/src/test/dataform.tsx new file mode 100644 index 00000000000000..534151a0a4ab58 --- /dev/null +++ b/packages/dataviews/src/test/dataform.tsx @@ -0,0 +1,348 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import Dataform from '../components/dataform/index'; + +const noop = () => {}; + +const fields = [ + { + id: 'title', + label: 'Title', + type: 'text' as const, + }, + { + id: 'order', + label: 'Order', + type: 'integer' as const, + }, + { + id: 'author', + label: 'Author', + type: 'integer' as const, + elements: [ + { value: 1, label: 'Jane' }, + { value: 2, label: 'John' }, + ], + }, +]; + +const form = { + fields: [ 'title', 'order', 'author' ], +}; + +const data = { + title: 'Hello World', + author: 1, + order: 1, +}; + +const fieldsSelector = { + title: { + view: () => + screen.getByRole( 'button', { + name: /edit title/i, + } ), + edit: () => + screen.getByRole( 'textbox', { + name: /title/i, + } ), + }, + author: { + view: () => + screen.getByRole( 'button', { + name: /edit author/i, + } ), + edit: () => + screen.queryByRole( 'combobox', { + name: /author/i, + } ), + }, + order: { + view: () => + screen.getByRole( 'button', { + name: /edit order/i, + } ), + edit: () => + screen.getByRole( 'spinbutton', { + name: /order/i, + } ), + }, +}; + +describe( 'DataForm component', () => { + describe( 'in regular mode', () => { + it( 'should display fields', () => { + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ form } + data={ data } + /> + ); + + expect( fieldsSelector.title.edit() ).toBeInTheDocument(); + expect( fieldsSelector.order.edit() ).toBeInTheDocument(); + expect( fieldsSelector.author.edit() ).toBeInTheDocument(); + } ); + + it( 'should render custom Edit component', () => { + const fieldsWithCustomEditComponent = fields.map( ( field ) => { + if ( field.id === 'title' ) { + return { + ...field, + Edit: () => { + return <span>This is the Title Field</span>; + }, + }; + } + return field; + } ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithCustomEditComponent } + form={ form } + data={ data } + /> + ); + + const titleField = screen.getByText( 'This is the Title Field' ); + expect( titleField ).toBeInTheDocument(); + } ); + + it( 'should call onChange with the correct value for each typed character', async () => { + const onChange = jest.fn(); + render( + <Dataform + onChange={ onChange } + fields={ fields } + form={ form } + data={ { ...data, title: '' } } + /> + ); + + const titleInput = fieldsSelector.title.edit(); + const user = userEvent.setup(); + await user.clear( titleInput ); + expect( titleInput ).toHaveValue( '' ); + const newValue = 'Hello folks!'; + await user.type( titleInput, newValue ); + expect( onChange ).toHaveBeenCalledTimes( newValue.length ); + for ( let i = 0; i < newValue.length; i++ ) { + expect( onChange ).toHaveBeenNthCalledWith( i + 1, { + title: newValue[ i ], + } ); + } + } ); + + it( 'should wrap fields in HStack when labelPosition is set to side', async () => { + const { container } = render( + <Dataform + onChange={ noop } + fields={ fields } + form={ { ...form, labelPosition: 'side' } } + data={ data } + /> + ); + + expect( + // It is used here to ensure that the fields are wrapped in HStack. This happens when the labelPosition is set to side. + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelectorAll( "[data-wp-component='HStack']" ) + ).toHaveLength( 3 ); + } ); + + it( 'should render combined fields correctly', async () => { + const formWithCombinedFields = { + fields: [ + 'order', + { + id: 'title', + children: [ 'title', 'author' ], + label: "Title and author's name", + }, + ], + }; + + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formWithCombinedFields } + data={ data } + /> + ); + + expect( + screen.getByText( "Title and author's name" ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'in panel mode', () => { + const formPanelMode = { + ...form, + type: 'panel' as const, + }; + it( 'should display fields', async () => { + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formPanelMode } + data={ data } + /> + ); + + const user = await userEvent.setup(); + + for ( const field of Object.values( fieldsSelector ) ) { + const button = field.view(); + await user.click( button ); + expect( field.edit() ).toBeInTheDocument(); + } + } ); + + it( 'should call onChange with the correct value for each typed character', async () => { + const onChange = jest.fn(); + render( + <Dataform + onChange={ onChange } + fields={ fields } + form={ formPanelMode } + data={ { ...data, title: '' } } + /> + ); + + const titleButton = fieldsSelector.title.view(); + const user = await userEvent.setup(); + await user.click( titleButton ); + const input = fieldsSelector.title.edit(); + expect( input ).toHaveValue( '' ); + const newValue = 'Hello folks!'; + await user.type( input, newValue ); + expect( onChange ).toHaveBeenCalledTimes( newValue.length ); + for ( let i = 0; i < newValue.length; i++ ) { + expect( onChange ).toHaveBeenNthCalledWith( i + 1, { + title: newValue[ i ], + } ); + } + } ); + + it( 'should wrap fields in HStack when labelPosition is set to side', async () => { + const { container } = render( + <Dataform + onChange={ noop } + fields={ fields } + form={ { ...formPanelMode, labelPosition: 'side' } } + data={ data } + /> + ); + + expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelectorAll( "[data-wp-component='HStack']" ) + ).toHaveLength( 3 ); + } ); + + it( 'should render combined fields correctly', async () => { + const formWithCombinedFields = { + ...formPanelMode, + fields: [ + 'order', + { + id: 'title', + children: [ 'title', 'author' ], + label: "Title and author's name", + }, + ], + }; + + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formWithCombinedFields } + data={ data } + /> + ); + + const button = screen.getByRole( 'button', { + name: /edit title and author's name/i, + } ); + const user = await userEvent.setup(); + await user.click( button ); + expect( fieldsSelector.title.edit() ).toBeInTheDocument(); + expect( fieldsSelector.author.edit() ).toBeInTheDocument(); + } ); + + it( 'should render custom render component', async () => { + const fieldsWithCustomRenderFunction = fields.map( ( field ) => { + return { + ...field, + render: () => { + return <span>This is the { field.id } field</span>; + }, + }; + } ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithCustomRenderFunction } + form={ formPanelMode } + data={ data } + /> + ); + + const titleField = screen.getByText( 'This is the title field' ); + const orderField = screen.getByText( 'This is the order field' ); + const authorField = screen.getByText( 'This is the author field' ); + expect( titleField ).toBeInTheDocument(); + expect( orderField ).toBeInTheDocument(); + expect( authorField ).toBeInTheDocument(); + } ); + + it( 'should render custom Edit component', async () => { + const fieldsWithTitleCustomEditComponent = fields.map( + ( field ) => { + if ( field.id === 'title' ) { + return { + ...field, + Edit: () => { + return <span>This is the Title Field</span>; + }, + }; + } + return field; + } + ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithTitleCustomEditComponent } + form={ formPanelMode } + data={ data } + /> + ); + + const titleField = screen.getByText( data.title ); + const user = await userEvent.setup(); + await user.click( titleField ); + const titleEditField = screen.getByText( + 'This is the Title Field' + ); + expect( titleEditField ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index 96fd4a8cd01afc..820f75364df204 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -322,6 +322,11 @@ interface ViewBase { * Whether to show the description */ showDescription?: boolean; + + /** + * Whether to show the hierarchical levels. + */ + showLevels?: boolean; } export interface ColumnStyle { @@ -480,6 +485,7 @@ export interface ViewBaseProps< Item > { data: Item[]; fields: NormalizedField< Item >[]; getItemId: ( item: Item ) => string; + getItemLevel?: ( item: Item ) => number; isLoading?: boolean; onChangeView: ( view: View ) => void; onChangeSelection: SetSelection; diff --git a/packages/dataviews/tsconfig.json b/packages/dataviews/tsconfig.json index 78e68b5a7c98b4..3f0865cc398544 100644 --- a/packages/dataviews/tsconfig.json +++ b/packages/dataviews/tsconfig.json @@ -4,7 +4,12 @@ "compilerOptions": { "rootDir": "src", "declarationDir": "build-types", - "checkJs": false + "types": [ + "gutenberg-env", + "gutenberg-test-env", + "jest", + "@testing-library/jest-dom" + ] }, "references": [ { "path": "../components" }, @@ -17,5 +22,13 @@ { "path": "../private-apis" }, { "path": "../warning" } ], - "include": [ "src" ] + "include": [ "src" ], + "exclude": [ + "src/**/*.android.js", + "src/**/*.ios.js", + "src/**/*.native.js", + "src/**/react-native-*", + "src/**/stories/**/*.js", // only exclude js files, tsx files should be checked + "src/**/test/**/*.js" // only exclude js files, ts{x} files should be checked + ] } diff --git a/packages/dom/src/dom/clean-node-list.js b/packages/dom/src/dom/clean-node-list.js index bbdff13b470d69..f1b7e1be7264f4 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; diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md index 30548961db26a4..5d445b2697d5ef 100644 --- a/packages/e2e-test-utils/README.md +++ b/packages/e2e-test-utils/README.md @@ -212,7 +212,7 @@ Create a new user account. _Parameters_ - _username_ `string`: User name. -- _object_ `Object?`: Optional Settings for the new user account. +- _object_ `?Object`: Optional Settings for the new user account. - _object.firstName_ `[string]`: First name. - _object.lastName_ `[string]`: Last name. - _object.role_ `[string]`: Role. Defaults to Administrator. @@ -252,7 +252,7 @@ Deletes a theme from the site, activating another theme if necessary. _Parameters_ - _slug_ `string`: Theme slug. -- _settings_ `Object?`: Optional settings object. +- _settings_ `?Object`: Optional settings object. - _settings.newThemeSlug_ `?string`: A theme to switch to if the theme to delete is active. Required if the theme to delete is active. - _settings.newThemeSearchTerm_ `?string`: A search term to use if the new theme is not findable by its slug. @@ -488,7 +488,7 @@ Installs a theme from the WP.org repository. _Parameters_ - _slug_ `string`: Theme slug. -- _settings_ `Object?`: Optional settings object. +- _settings_ `?Object`: Optional settings object. - _settings.searchTerm_ `?string`: Search term to use if the theme is not findable by its slug. ### isCurrentURL diff --git a/packages/e2e-test-utils/src/create-user.js b/packages/e2e-test-utils/src/create-user.js index 317f23c4c58d40..ac28a59482e381 100644 --- a/packages/e2e-test-utils/src/create-user.js +++ b/packages/e2e-test-utils/src/create-user.js @@ -14,7 +14,7 @@ import { visitAdminPage } from './visit-admin-page'; * Create a new user account. * * @param {string} username User name. - * @param {Object?} object Optional Settings for the new user account. + * @param {?Object} object Optional Settings for the new user account. * @param {string} [object.firstName] First name. * @param {string} [object.lastName] Last name. * @param {string} [object.role] Role. Defaults to Administrator. diff --git a/packages/e2e-test-utils/src/delete-theme.js b/packages/e2e-test-utils/src/delete-theme.js index 8b59c9f1e7a112..b09bc6424b99bd 100644 --- a/packages/e2e-test-utils/src/delete-theme.js +++ b/packages/e2e-test-utils/src/delete-theme.js @@ -12,7 +12,7 @@ import { isThemeInstalled } from './theme-installed'; * Deletes a theme from the site, activating another theme if necessary. * * @param {string} slug Theme slug. - * @param {Object?} settings Optional settings object. + * @param {?Object} settings Optional settings object. * @param {?string} settings.newThemeSlug A theme to switch to if the theme to delete is active. Required if the theme to delete is active. * @param {?string} settings.newThemeSearchTerm A search term to use if the new theme is not findable by its slug. */ diff --git a/packages/e2e-test-utils/src/install-theme.js b/packages/e2e-test-utils/src/install-theme.js index 7f11e5da88ef83..8adf7fe58a20cf 100644 --- a/packages/e2e-test-utils/src/install-theme.js +++ b/packages/e2e-test-utils/src/install-theme.js @@ -10,7 +10,7 @@ import { isThemeInstalled } from './theme-installed'; * Installs a theme from the WP.org repository. * * @param {string} slug Theme slug. - * @param {Object?} settings Optional settings object. + * @param {?Object} settings Optional settings object. * @param {?string} settings.searchTerm Search term to use if the theme is not findable by its slug. */ export async function installTheme( slug, { searchTerm } = {} ) { 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 47eb351d837e78..bfac62feb13595 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" ></data> </div> + +<hr> + +<div + data-wp-interactive="directive-each" + data-testid="each-with-unset" +> + <template data-wp-each="state.eachUnset"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-null" +> + <template data-wp-each="state.eachNull"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-undefined" +> + <template data-wp-each="state.eachUndefined"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-array" +> + <template data-wp-each="state.eachArray"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-set" +> + <template data-wp-each="state.eachSet"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-string" +> + <template data-wp-each="state.eachString"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-generator" +> + <template data-wp-each="state.eachGenerator"><p data-wp-text="context.item"></p></template> +</div> +<div + data-wp-interactive="directive-each" + data-testid="each-with-iterator" +> + <template data-wp-each="state.eachIterator"><p data-wp-text="context.item"></p></template> +</div> 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 6ceef82864d9db..7577810b6bb876 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/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index f6516dd0206c00..58c802f579e0d1 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -550,7 +550,7 @@ export function areMetaBoxesInitialized( state ) { /** * Retrieves the template of the currently edited post. * - * @return {Object?} Post Template. + * @return {?Object} Post Template. */ export const getEditedPostTemplate = createRegistrySelector( ( select ) => () => { diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 51045be503de31..299f0a67da9b7a 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -56,6 +56,7 @@ "@wordpress/icons": "*", "@wordpress/keyboard-shortcuts": "*", "@wordpress/keycodes": "*", + "@wordpress/media-utils": "5.14.0", "@wordpress/notices": "*", "@wordpress/patterns": "*", "@wordpress/plugins": "*", 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 52ac29da0696f6..07d666fb293c59 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,10 @@ .edit-site-editor-canvas-container { height: 100%; + // This is the gray background color that's applied behind "isolation mode". + // The color normally comes from .editor-visual-editor, but that class is missing here. + background-color: $gray-300; + // Controls height of editor and editor canvas container (style book, global styles revisions previews etc.) iframe { display: block; diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index c045bafd8a6839..ad88ee07e2150f 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 && <SaveButton size="compact" /> 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 25dcc69185cae6..cca4a26e1b7368 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 } > - <Menu - trigger={ - <Button - size="small" - icon={ moreVertical } - label={ __( 'Font size options' ) } - /> - } - > - <Menu.Item onClick={ toggleRenameDialog }> - <Menu.ItemLabel> - { __( 'Rename' ) } - </Menu.ItemLabel> - </Menu.Item> - <Menu.Item onClick={ toggleDeleteConfirm }> - <Menu.ItemLabel> - { __( 'Delete' ) } - </Menu.ItemLabel> - </Menu.Item> + <Menu> + <Menu.TriggerButton + render={ + <Button + size="small" + icon={ moreVertical } + label={ __( + 'Font size options' + ) } + /> + } + /> + <Menu.Popover> + <Menu.Item + onClick={ toggleRenameDialog } + > + <Menu.ItemLabel> + { __( 'Rename' ) } + </Menu.ItemLabel> + </Menu.Item> + <Menu.Item + onClick={ toggleDeleteConfirm } + > + <Menu.ItemLabel> + { __( 'Delete' ) } + </Menu.ItemLabel> + </Menu.Item> + </Menu.Popover> </Menu> </Spacer> </FlexItem> 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 7498dd7c78fb30..5b759d1e0468d8 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 && ( - <Menu - trigger={ - <Button - size="small" - icon={ moreVertical } - label={ __( - 'Font size presets options' - ) } - /> - } - > - <Menu.Item onClick={ toggleResetDialog }> - <Menu.ItemLabel> - { origin === 'custom' - ? __( 'Remove font size presets' ) - : __( 'Reset font size presets' ) } - </Menu.ItemLabel> - </Menu.Item> + <Menu> + <Menu.TriggerButton + render={ + <Button + size="small" + icon={ moreVertical } + label={ __( + 'Font size presets options' + ) } + /> + } + /> + <Menu.Popover> + <Menu.Item onClick={ toggleResetDialog }> + <Menu.ItemLabel> + { origin === 'custom' + ? __( + 'Remove font size presets' + ) + : __( + 'Reset font size presets' + ) } + </Menu.ItemLabel> + </Menu.Item> + </Menu.Popover> </Menu> ) } </FlexItem> 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 0de1f2c99362ca..9fd7959a6c09cb 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() { <ScreenHeader title={ selectedShadow.name } /> <FlexItem> <Spacer marginTop={ 2 } marginBottom={ 0 } paddingX={ 4 }> - <Menu - trigger={ - <Button - size="small" - icon={ moreVertical } - label={ __( 'Menu' ) } - /> - } - > - { ( category === 'custom' - ? customShadowMenuItems - : presetShadowMenuItems - ).map( ( item ) => ( - <Menu.Item - key={ item.action } - onClick={ () => onMenuClick( item.action ) } - disabled={ - item.action === 'reset' && - selectedShadow.shadow === - baseSelectedShadow.shadow - } - > - <Menu.ItemLabel> - { item.label } - </Menu.ItemLabel> - </Menu.Item> - ) ) } + <Menu> + <Menu.TriggerButton + render={ + <Button + size="small" + icon={ moreVertical } + label={ __( 'Menu' ) } + /> + } + /> + <Menu.Popover> + { ( category === 'custom' + ? customShadowMenuItems + : presetShadowMenuItems + ).map( ( item ) => ( + <Menu.Item + key={ item.action } + onClick={ () => + onMenuClick( item.action ) + } + disabled={ + item.action === 'reset' && + selectedShadow.shadow === + baseSelectedShadow.shadow + } + > + <Menu.ItemLabel> + { item.label } + </Menu.ItemLabel> + </Menu.Item> + ) ) } + </Menu.Popover> </Menu> </Spacer> </FlexItem> diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 68cc40c4b62066..99b1c8c92bbd02 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -169,10 +169,18 @@ top: $grid-unit; opacity: 0; + &.edit-site-global-styles__shadow-editor__remove-button { + border: none; + } + .edit-site-global-styles__shadow-editor__dropdown-toggle:hover + &, &:focus, &:hover { - border: none; + opacity: 1; + } + + @media (hover: none) { + // Show reset button on devices that do not support hover. opacity: 1; } } diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index 20162c5272f2ef..a5e14f0be82816 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/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss index 1ee6ceb94ddbfc..d5520e5d97cdfc 100644 --- a/packages/edit-site/src/components/page-patterns/style.scss +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -92,7 +92,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .edit-site-page-patterns-dataviews .edit-site-patterns__section-header { padding-left: $grid-unit-30; diff --git a/packages/edit-site/src/components/page/style.scss b/packages/edit-site/src/components/page/style.scss index 03e062a576b6e6..7759081e36f592 100644 --- a/packages/edit-site/src/components/page/style.scss +++ b/packages/edit-site/src/components/page/style.scss @@ -41,7 +41,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .edit-site-page-header { padding: $grid-unit-20 $grid-unit-30; diff --git a/packages/edit-site/src/components/post-list/index.js b/packages/edit-site/src/components/post-list/index.js index bbf5d654ddb57a..6ab3a47efb4653 100644 --- a/packages/edit-site/src/components/post-list/index.js +++ b/packages/edit-site/src/components/post-list/index.js @@ -194,6 +194,10 @@ function getItemId( item ) { return item.id.toString(); } +function getItemLevel( item ) { + return item.level; +} + export default function PostList( { postType } ) { const [ view, setView ] = useView( postType ); const defaultViews = useDefaultViews( { postType } ); @@ -219,7 +223,6 @@ export default function PostList( { postType } ) { }, [ location.path, location.query.isCustom, history ] ); - const getActiveViewFilters = ( views, match ) => { const found = views.find( ( { slug } ) => slug === match ); return found?.filters ?? []; @@ -300,6 +303,7 @@ export default function PostList( { postType } ) { _embed: 'author', order: view.sort?.direction, orderby: view.sort?.field, + orderby_hierarchy: !! view.showLevels, search: view.search, ...filters, }; @@ -421,6 +425,7 @@ export default function PostList( { postType } ) { history.navigate( `/${ postType }/${ id }?canvas=edit` ); } } getItemId={ getItemId } + getItemLevel={ getItemLevel } defaultLayouts={ defaultLayouts } header={ window.__experimentalQuickEditDataViews && 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 847029e8d6dcfe..467648e814276d 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 } ) { <div className="edit-site-sidebar-navigation-screen-dataviews__group-header"> <Heading level={ 2 }>{ __( 'Custom Views' ) }</Heading> </div> - <ItemGroup> + <ItemGroup className="edit-site-sidebar-navigation-screen-dataviews__custom-items"> { customDataViews.map( ( customViewRecord ) => { return ( <CustomDataViewItem diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index c08a2c1a57c58e..c6edf7d2dd1203 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -39,9 +39,10 @@ const DEFAULT_POST_BASE = { page: 1, perPage: 20, sort: { - field: 'date', - direction: 'desc', + field: 'title', + direction: 'asc', }, + showLevels: true, titleField: 'title', mediaField: 'featured_media', fields: [ 'author', 'status' ], diff --git a/packages/edit-site/src/components/sidebar-dataviews/style.scss b/packages/edit-site/src/components/sidebar-dataviews/style.scss index 6d4c01e1a222b6..36eabd0b4c079b 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/style.scss +++ b/packages/edit-site/src/components/sidebar-dataviews/style.scss @@ -12,9 +12,12 @@ margin-right: -$grid-unit-20; } +.edit-site-sidebar-navigation-screen-dataviews__custom-items .edit-site-sidebar-dataviews-dataview-item { + padding-right: $grid-unit-10; +} + .edit-site-sidebar-dataviews-dataview-item { border-radius: $radius-small; - padding-right: $grid-unit-10; .edit-site-sidebar-dataviews-dataview-item__dropdown-menu { min-width: initial; 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 980f20c49821b0..de12bbe466bf3b 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 { addQueryArgs } from '@wordpress/url'; +import { Button } from '@wordpress/components'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; +import { seen } from '@wordpress/icons'; /** * Internal dependencies @@ -17,58 +15,42 @@ import { addQueryArgs } from '@wordpress/url'; 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 GlobalStylesPageActions = ( { isStyleBookOpened, setIsStyleBookOpened, + path, } ) => { + const history = useHistory(); return ( - <Menu - trigger={ - <Button __next40pxDefaultSize variant="tertiary" size="compact"> - { __( 'Preview' ) } - </Button> - } - > - <Menu.RadioItem - value - checked={ isStyleBookOpened } - name="styles-preview-actions" - onChange={ () => setIsStyleBookOpened( true ) } - defaultChecked - > - <Menu.ItemLabel>{ __( 'Style book' ) }</Menu.ItemLabel> - <Menu.ItemHelpText> - { __( 'Preview blocks and styles.' ) } - </Menu.ItemHelpText> - </Menu.RadioItem> - <Menu.RadioItem - value={ false } - checked={ ! isStyleBookOpened } - name="styles-preview-actions" - onChange={ () => setIsStyleBookOpened( false ) } - > - <Menu.ItemLabel>{ __( 'Site' ) }</Menu.ItemLabel> - <Menu.ItemHelpText> - { __( 'Preview your site.' ) } - </Menu.ItemHelpText> - </Menu.RadioItem> - </Menu> + <Button + isPressed={ isStyleBookOpened } + icon={ seen } + label={ __( 'Style Book' ) } + onClick={ () => { + 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 ) => { @@ -80,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 ( <> @@ -89,6 +81,7 @@ export default function GlobalStylesUIWrapper() { <GlobalStylesPageActions isStyleBookOpened={ isStyleBookOpened } setIsStyleBookOpened={ setIsStyleBookOpened } + path={ path } /> ) : null } @@ -100,45 +93,6 @@ export default function GlobalStylesUIWrapper() { onPathChange={ onChangeSection } /> </Page> - { canvas === 'view' && isStyleBookOpened && ( - <StyleBook - enableResizing={ false } - showCloseButton={ false } - showTabs={ false } - isSelected={ ( blockName ) => - // 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/sidebar-global-styles-wrapper/style.scss b/packages/edit-site/src/components/sidebar-global-styles-wrapper/style.scss index 88aa9ddf0c1618..0fa4e158fe7f10 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; + } + } + + } + } +} 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 ac1cf8b730861d..57b7e84bd57a8b 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -18,6 +18,12 @@ &[aria-current="true"] { background: $gray-800; color: $white; + font-weight: $font-weight-medium; + } + + // 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 { 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 a30c4a7c04945e..cb1e414098ab3f 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: <StyleBookPreview />, - mobile: <StyleBookPreview />, + preview: <StyleBookPreview isStatic />, + mobile: <StyleBookPreview isStatic />, }, }; 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 cf29dbebea3733..a1827bee763390 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: <GlobalStylesUIWrapper />, sidebar: <SidebarNavigationScreenGlobalStyles backPath="/" />, - preview: <Editor />, + preview( { query } ) { + const isStylebook = query.preview === 'stylebook'; + return isStylebook ? <StyleBookPreview /> : <Editor />; + }, mobile: <MobileGlobalStylesUI />, }, widths: { diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index da69ed734166ed..28a693e76958d7 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -32,8 +32,11 @@ import { useContext, useRef, useLayoutEffect, + useEffect, } from '@wordpress/element'; import { ENTER, SPACE } from '@wordpress/keycodes'; +import { uploadMedia } from '@wordpress/media-utils'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -47,6 +50,9 @@ 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'; +import { GlobalStylesRenderer } from '../global-styles-renderer'; const { ExperimentalBlockEditorProvider, @@ -346,25 +352,67 @@ 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(), [] ); + + const canUserUploadMedia = useSelect( + ( select ) => + select( coreStore ).canUser( 'create', { + kind: 'root', + name: 'media', + } ), + [] + ); + // Update block editor settings because useMultipleOriginColorsAndGradients fetch colours from there. - dispatch( blockEditorStore ).updateSettings( siteEditorSettings ); + useEffect( () => { + dispatch( blockEditorStore ).updateSettings( { + ...siteEditorSettings, + mediaUpload: canUserUploadMedia ? uploadMedia : undefined, + } ); + }, [ siteEditorSettings, canUserUploadMedia ] ); + + 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 +420,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 ) ) { @@ -399,13 +447,14 @@ export const StyleBookPreview = ( { <div className="edit-site-style-book"> { resizeObserver } <BlockEditorProvider settings={ settings }> + <GlobalStylesRenderer disableRootPadding /> <StyleBookBody examples={ examplesForSinglePageUse } settings={ settings } goTo={ goTo } sizes={ sizes } - isSelected={ isSelected } - onSelect={ onSelect } + isSelected={ ! isStatic ? isSelected : null } + onSelect={ ! isStatic ? onSelect : null } /> </BlockEditorProvider> </div> diff --git a/packages/edit-widgets/src/store/transformers.js b/packages/edit-widgets/src/store/transformers.js index 3b42e3141ff5f0..12a2f9d32933a8 100644 --- a/packages/edit-widgets/src/store/transformers.js +++ b/packages/edit-widgets/src/store/transformers.js @@ -46,7 +46,7 @@ export function transformWidgetToBlock( widget ) { * Converts a block to a widget entity record. * * @param {Object} block The block. - * @param {Object?} relatedWidget A related widget entity record from the API (optional). + * @param {?Object} relatedWidget A related widget entity record from the API (optional). * @return {Object} the widget object (converted from block). */ export function transformBlockToWidget( block, relatedWidget = {} ) { diff --git a/packages/editor/README.md b/packages/editor/README.md index 3211e6664256d0..c006ec097982c9 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/commands/index.js b/packages/editor/src/components/commands/index.js index 0040a09fbdc07d..d495dcaaef3379 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, diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index ad584b0df75574..200473cccff706 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 ( <div ref={ renderDialog ? saveDialogRef : undefined } @@ -180,7 +184,7 @@ export function EntitiesSavedStatesExtensible( { ), { strong: <strong /> } ) - : __( 'Select the items you want to save.' ) } + : selectItemsToSaveDescription } </p> </div> diff --git a/packages/editor/src/components/error-boundary/index.native.js b/packages/editor/src/components/error-boundary/index.native.js index 0de048e8114451..4c05ceb3fc150b 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 { <View style={ styles[ 'error-boundary__container' ] }> <View style={ iconContainerStyle }> <Icon - icon={ warning } + icon={ cautionFilled } { ...styles[ 'error-boundary__icon' ] } /> </View> diff --git a/packages/editor/src/components/more-menu/index.js b/packages/editor/src/components/more-menu/index.js index 9e062e5e5adc50..f5eaa45e4ed696 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() { <ActionItem.Slot name="core/plugin-more-menu" label={ __( 'Plugins' ) } - as={ MenuGroup } fillProps={ { onClick: onClose } } /> <MenuGroup label={ __( 'Tools' ) }> diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index 808134ea969a11..023b93d31bb511 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/index.js b/packages/editor/src/components/post-actions/index.js index 4b541d52d429cc..d6adf6c0721667 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 ( <> - <Menu - trigger={ - <Button - size="small" - icon={ moreVertical } - label={ __( 'Actions' ) } - disabled={ ! actions.length } - accessibleWhenDisabled - className="editor-all-actions-button" - /> - } - placement="bottom-end" - > - <ActionsDropdownMenuGroup - actions={ actions } - items={ itemsWithPermissions } - setActiveModalAction={ setActiveModalAction } + <Menu placement="bottom-end"> + <Menu.TriggerButton + render={ + <Button + size="small" + icon={ moreVertical } + label={ __( 'Actions' ) } + disabled={ ! actions.length } + accessibleWhenDisabled + className="editor-all-actions-button" + /> + } /> + <Menu.Popover> + <ActionsDropdownMenuGroup + actions={ actions } + items={ itemsWithPermissions } + setActiveModalAction={ setActiveModalAction } + /> + </Menu.Popover> </Menu> { !! activeModalAction && ( <ActionModal 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 0252c84e3ab3ff..671906575b4123 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 00000000000000..67c42a7991fe45 --- /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 ( + <form onSubmit={ onSetPageAsPostsPage }> + <VStack spacing="5"> + <Text>{ modalText }</Text> + <HStack justify="right"> + <Button + __next40pxDefaultSize + variant="tertiary" + onClick={ () => { + closeModal?.(); + } } + disabled={ isSaving } + accessibleWhenDisabled + > + { __( 'Cancel' ) } + </Button> + <Button + __next40pxDefaultSize + variant="primary" + type="submit" + disabled={ isSaving } + accessibleWhenDisabled + > + { modalButtonLabel } + </Button> + </HStack> + </VStack> + </form> + ); +}; + +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/components/post-card-panel/index.js b/packages/editor/src/components/post-card-panel/index.js index 7849f014ab49c8..78f9522ba5f444 100644 --- a/packages/editor/src/components/post-card-panel/index.js +++ b/packages/editor/src/components/post-card-panel/index.js @@ -6,6 +6,7 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, __experimentalText as Text, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; @@ -25,6 +26,7 @@ import { unlock } from '../../lock-unlock'; import PostActions from '../post-actions'; import usePageTypeBadge from '../../utils/pageTypeBadge'; import { getTemplateInfo } from '../../utils/get-template-info'; +const { Badge } = unlock( componentsPrivateApis ); /** * Renders a title of the post type and the available quick actions available within a 3-dot dropdown. @@ -109,11 +111,11 @@ export default function PostCardPanel( { className="editor-post-card-panel__title" as="h2" > - { title } + <span className="editor-post-card-panel__title-name"> + { title } + </span> { pageTypeBadge && postIds.length === 1 && ( - <span className="editor-post-card-panel__title-badge"> - { pageTypeBadge } - </span> + <Badge>{ pageTypeBadge }</Badge> ) } </Text> <PostActions diff --git a/packages/editor/src/components/post-card-panel/style.scss b/packages/editor/src/components/post-card-panel/style.scss index c3638b313a8285..5fa54c67f14e55 100644 --- a/packages/editor/src/components/post-card-panel/style.scss +++ b/packages/editor/src/components/post-card-panel/style.scss @@ -9,7 +9,6 @@ &.editor-post-card-panel__title { @include heading-medium(); margin: 0; - padding: 2px 0; display: flex; align-items: center; flex-wrap: wrap; @@ -34,19 +33,11 @@ margin-bottom: $grid-unit-10; } + .editor-post-card-panel__title-name { + padding: 2px 0; + } + .editor-post-card-panel__description { color: $gray-700; } } - -.editor-post-card-panel__title-badge { - background: $gray-100; - color: $gray-800; - padding: 0 $grid-unit-05; - border-radius: $radius-small; - font-size: 12px; - font-weight: 400; - flex-shrink: 0; - line-height: $grid-unit-05 * 5; - display: inline-block; -} diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js index 6fa35c673430cc..a081564e48ea8d 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 } ) { ) } <ActionItem.Slot name="core/plugin-preview-menu" - as={ MenuGroup } fillProps={ { onClick: onClose } } /> </> 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 f5c45f431e2c85..d0c2e36d474433 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/components/template-part-menu-items/index.js b/packages/editor/src/components/template-part-menu-items/index.js index 0e126644d49938..52c50f91b3933c 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 ( diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index 74c1f1ea100b37..ef2eb093f793e4 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -34,7 +34,7 @@ export function setCurrentTemplateId( id ) { /** * Create a block based template. * - * @param {Object?} template Template to create and assign. + * @param {?Object} template Template to create and assign. */ export const createTemplate = ( template ) => 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 00000000000000..86929c27408a81 --- /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/packages/editor/src/utils/media-sideload/index.js b/packages/editor/src/utils/media-sideload/index.js new file mode 100644 index 00000000000000..86fcdc688abf8f --- /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 00000000000000..d84a912ec92de0 --- /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 6b39db2443cc33..0d970d91ce745c 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/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 252e1e65dae872..59c8998aba5cc5 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,12 +2,16 @@ ## Unreleased +### Internal + +- Refactored the code to use new API introduced together with `@inquirer/prompts` instead of legacy `inquirer` package ([#67877](https://github.com/WordPress/gutenberg/pull/67877)). + ## 10.14.0 (2024-12-11) ### Enhancements -- Add phpMyAdmin as an optional service. Enabled via the new `phpmyadminPort` environment config, as well as env vars `WP_ENV_PHPMYADMIN_PORT` and `WP_ENV_TESTS_PHPMYADMIN_PORT`. -- Add support for WordPress multisite installations. Enabled via the new `multisite` environment config. +- Add phpMyAdmin as an optional service. Enabled via the new `phpmyadminPort` environment config, as well as env vars `WP_ENV_PHPMYADMIN_PORT` and `WP_ENV_TESTS_PHPMYADMIN_PORT`. +- Add support for WordPress multisite installations. Enabled via the new `multisite` environment config. ### Internal diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index 46b923dc3c9aca..016838ea218442 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -5,7 +5,7 @@ const { v2: dockerCompose } = require( 'docker-compose' ); const fs = require( 'fs' ).promises; const path = require( 'path' ); -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); /** * Promisified dependencies @@ -40,14 +40,19 @@ module.exports = async function destroy( { spinner, scripts, debug } ) { 'WARNING! This will remove Docker containers, volumes, networks, and images associated with the WordPress instance.' ); - const { yesDelete } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesDelete', + let yesDelete = false; + try { + yesDelete = await confirm( { message: 'Are you sure you want to continue?', default: false, - }, - ] ); + } ); + } catch ( error ) { + if ( error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } spinner.start(); diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index e476fd8c2b67b7..db05b82060d2c5 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -6,7 +6,7 @@ const { v2: dockerCompose } = require( 'docker-compose' ); const util = require( 'util' ); const path = require( 'path' ); const fs = require( 'fs' ).promises; -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); /** * Promisified dependencies @@ -328,15 +328,21 @@ async function checkForLegacyInstall( spinner ) { ' and ' ) }. Installs are now in your home folder.\n` ); - const { yesDelete } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesDelete', + let yesDelete = false; + try { + yesDelete = confirm( { message: 'Do you wish to delete these old installs to reclaim disk space?', default: true, - }, - ] ); + } ); + } catch ( error ) { + if ( error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } + if ( yesDelete ) { await Promise.all( installs.map( ( install ) => rimraf( install ) ) ); spinner.info( 'Old installs deleted successfully.' ); diff --git a/packages/env/package.json b/packages/env/package.json index d86d518e41e497..f28345746b5891 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -36,12 +36,12 @@ "wp-env": "bin/wp-env" }, "dependencies": { + "@inquirer/prompts": "^7.2.0", "chalk": "^4.0.0", "copy-dir": "^1.3.0", "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", - "inquirer": "^7.1.0", "js-yaml": "^3.13.1", "ora": "^4.0.2", "rimraf": "^5.0.10", 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 4043a7824fac49..8728f2681a4936 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 /> - <BaseControl - __nextHasNoMarginBottom - label={ __( 'Area' ) } - id={ `fields-create-template-part-modal__area-selection-${ instanceId }` } - className="fields-create-template-part-modal__area-base-control" - > - <RadioGroup - label={ __( 'Area' ) } - className="fields-create-template-part-modal__area-radio-group" - id={ `fields-create-template-part-modal__area-selection-${ instanceId }` } - onChange={ ( value ) => - value && typeof value === 'string' - ? setArea( value ) - : () => void 0 - } - checked={ area } - > + <fieldset> + <BaseControl.VisualLabel as="legend"> + { __( 'Area' ) } + </BaseControl.VisualLabel> + <div className="fields-create-template-part-modal__area-radio-group"> { ( defaultTemplatePartAreas ?? [] ).map( ( item ) => { const icon = getTemplatePartIcon( item.icon ); return ( - <Radio - __next40pxDefaultSize - key={ item.label } - value={ item.area } - className="fields-create-template-part-modal__area-radio" + <div + key={ item.area } + className="fields-create-template-part-modal__area-radio-wrapper" > - <Flex align="start" justify="start"> - <FlexItem> - <Icon icon={ icon } /> - </FlexItem> - <FlexBlock className="fields-create-template-part-modal__option-label"> - { item.label } - <div>{ item.description }</div> - </FlexBlock> - - <FlexItem className="fields-create-template-part-modal__checkbox"> - { area === item.area && ( - <Icon icon={ check } /> - ) } - </FlexItem> - </Flex> - </Radio> + <input + type="radio" + id={ getAreaRadioId( + item.area, + instanceId + ) } + name={ `fields-create-template-part-modal__area-${ instanceId }` } + value={ item.area } + checked={ area === item.area } + onChange={ () => { + setArea( item.area ); + } } + aria-describedby={ getAreaRadioDescriptionId( + item.area, + instanceId + ) } + /> + <Icon + icon={ icon } + className="fields-create-template-part-modal__area-radio-icon" + /> + <label + htmlFor={ getAreaRadioId( + item.area, + instanceId + ) } + className="fields-create-template-part-modal__area-radio-label" + > + { item.label } + </label> + <Icon + icon={ check } + className="fields-create-template-part-modal__area-radio-checkmark" + /> + <p + className="fields-create-template-part-modal__area-radio-description" + id={ getAreaRadioDescriptionId( + item.area, + instanceId + ) } + > + { item.description } + </p> + </div> ); } ) } - </RadioGroup> - </BaseControl> + </div> + </fieldset> <HStack justify="right"> <Button __next40pxDefaultSize diff --git a/packages/fields/src/components/create-template-part-modal/style.scss b/packages/fields/src/components/create-template-part-modal/style.scss index fedc0326648c2e..bba250b8f3a262 100644 --- a/packages/fields/src/components/create-template-part-modal/style.scss +++ b/packages/fields/src/components/create-template-part-modal/style.scss @@ -3,61 +3,86 @@ } .fields-create-template-part-modal__area-radio-group { - width: 100%; - border: $border-width solid $gray-700; + border: $border-width solid $gray-600; border-radius: $radius-small; +} + +.fields-create-template-part-modal__area-radio-wrapper { + position: relative; + padding: $grid-unit-15; + + display: grid; + align-items: center; + grid-template-columns: min-content 1fr min-content; + grid-gap: $grid-unit-05 $grid-unit-10; + + color: $gray-900; + + & + & { + border-top: $border-width solid $gray-600; + } + + input[type="radio"] { + position: absolute; + opacity: 0; + } + + &:has(input[type="radio"]:checked) { + // This is needed to make sure that the focus ring always renders on top + // of the sibling radio "wrapper"'s borders. + z-index: 1; + } + + &:has(input[type="radio"]:not(:checked)):hover { + color: var(--wp-admin-theme-color); + } + + // Pass-through pointer events, so that the corresponding radio input + // gets checked when clicking on the underlying label + > *: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; } } diff --git a/packages/fields/src/fields/page-title/style.scss b/packages/fields/src/fields/page-title/style.scss deleted file mode 100644 index def56aa466a8a1..00000000000000 --- a/packages/fields/src/fields/page-title/style.scss +++ /dev/null @@ -1,10 +0,0 @@ -.fields-field__page-title__badge { - background: $gray-100; - color: $gray-800; - padding: 0 $grid-unit-05; - border-radius: $radius-small; - font-size: 12px; - font-weight: 400; - flex-shrink: 0; - line-height: $grid-unit-05 * 5; -} diff --git a/packages/fields/src/fields/page-title/view.tsx b/packages/fields/src/fields/page-title/view.tsx index 0be4c16d5d29ae..eb5184362ec82b 100644 --- a/packages/fields/src/fields/page-title/view.tsx +++ b/packages/fields/src/fields/page-title/view.tsx @@ -5,12 +5,15 @@ import { __ } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import type { Settings } from '@wordpress/core-data'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies */ import type { CommonPost } from '../../types'; import { BaseTitleView } from '../title/view'; +import { unlock } from '../../lock-unlock'; +const { Badge } = unlock( componentsPrivateApis ); export default function PageTitleView( { item }: { item: CommonPost } ) { const { frontPageId, postsPageId } = useSelect( ( select ) => { @@ -27,11 +30,11 @@ export default function PageTitleView( { item }: { item: CommonPost } ) { return ( <BaseTitleView item={ item } className="fields-field__page-title"> { [ frontPageId, postsPageId ].includes( item.id as number ) && ( - <span className="fields-field__page-title__badge"> + <Badge> { item.id === frontPageId ? __( 'Homepage' ) : __( 'Posts Page' ) } - </span> + </Badge> ) } </BaseTitleView> ); diff --git a/packages/fields/src/style.scss b/packages/fields/src/style.scss index d9a571270fbb68..96b1f816de5b61 100644 --- a/packages/fields/src/style.scss +++ b/packages/fields/src/style.scss @@ -3,5 +3,4 @@ @import "./fields/featured-image/style.scss"; @import "./fields/template/style.scss"; @import "./fields/title/style.scss"; -@import "./fields/page-title/style.scss"; @import "./fields/pattern-title/style.scss"; diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index d622019f1ee783..64c1a58b549caf 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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) ## 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 8cbf65d9f259e9..406f986e6ef5dc 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 3fd962e047bc1d..4de5ae9a7dae93 100644 --- a/packages/icons/src/icon/stories/keywords.ts +++ b/packages/icons/src/icon/stories/keywords.ts @@ -1,13 +1,15 @@ const keywords: Partial< Record< keyof typeof import('../../'), string[] > > = { cancelCircleFilled: [ 'close' ], + caution: [ 'alert', 'warning' ], + cautionFilled: [ 'alert', 'warning' ], create: [ 'add' ], + error: [ 'alert', 'caution', 'warning' ], file: [ 'folder' ], seen: [ 'show' ], thumbsDown: [ 'dislike' ], 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 14eaec92b78c4d..e82b09e5d5afe9 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -37,6 +37,12 @@ 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, + 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'; @@ -84,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'; @@ -301,6 +308,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 00000000000000..5e7779db85f862 --- /dev/null +++ b/packages/icons/src/library/caution-filled.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const cautionFilled = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM12.75 8V13H11.25V8H12.75ZM12.75 14.5V16H11.25V14.5H12.75Z" /> + </SVG> +); + +export default cautionFilled; diff --git a/packages/icons/src/library/caution.js b/packages/icons/src/library/caution.js new file mode 100644 index 00000000000000..f6d23fdfc7eddf --- /dev/null +++ b/packages/icons/src/library/caution.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const caution = ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.75 12v-1.5h1.5V16h-1.5Zm0-8v5h1.5V8h-1.5Z" + /> + </SVG> +); + +export default caution; diff --git a/packages/icons/src/library/error.js b/packages/icons/src/library/error.js new file mode 100644 index 00000000000000..2dc2bccbf639ce --- /dev/null +++ b/packages/icons/src/library/error.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const error = ( + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M12.218 5.377a.25.25 0 0 0-.436 0l-7.29 12.96a.25.25 0 0 0 .218.373h14.58a.25.25 0 0 0 .218-.372l-7.29-12.96Zm-1.743-.735c.669-1.19 2.381-1.19 3.05 0l7.29 12.96a1.75 1.75 0 0 1-1.525 2.608H4.71a1.75 1.75 0 0 1-1.525-2.608l7.29-12.96ZM12.75 17.46h-1.5v-1.5h1.5v1.5Zm-1.5-3h1.5v-5h-1.5v5Z" + /> + </SVG> +); + +export default error; diff --git a/packages/icons/src/library/info.js b/packages/icons/src/library/info.js index f3425d9e950415..24d41d798263f7 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 = ( - <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M12 3.2c-4.8 0-8.8 3.9-8.8 8.8 0 4.8 3.9 8.8 8.8 8.8 4.8 0 8.8-3.9 8.8-8.8 0-4.8-4-8.8-8.8-8.8zm0 16c-4 0-7.2-3.3-7.2-7.2C4.8 8 8 4.8 12 4.8s7.2 3.3 7.2 7.2c0 4-3.2 7.2-7.2 7.2zM11 17h2v-6h-2v6zm0-8h2V7h-2v2z" /> + <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M5.5 12a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0ZM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm.75 4v1.5h-1.5V8h1.5Zm0 8v-5h-1.5v5h1.5Z" + /> </SVG> ); diff --git a/packages/icons/src/library/warning.js b/packages/icons/src/library/warning.js deleted file mode 100644 index 97086c5c9292bd..00000000000000 --- a/packages/icons/src/library/warning.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * WordPress dependencies - */ -import { SVG, Path } from '@wordpress/primitives'; - -const warning = ( - <SVG xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24"> - <Path d="M10 2c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8 3.58-8 8-8zm1.13 9.38l.35-6.46H8.52l.35 6.46h2.26zm-.09 3.36c.24-.23.37-.55.37-.96 0-.42-.12-.74-.36-.97s-.59-.35-1.06-.35-.82.12-1.07.35-.37.55-.37.97c0 .41.13.73.38.96.26.23.61.34 1.06.34s.8-.11 1.05-.34z" /> - </SVG> -); - -export default warning; diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 6b32e4ec35a978..ec54cdfd7c0367 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 31e07d095e0a4c..bddd017b1c99db 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 e9b9f48ba3518e..7899e3eafd2281 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/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md index a0ed9cd83525cc..172d70b09fad31 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 15c627adfd3296..5611e044c8a985 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 4bd5a11e8d71f8..2f3fdd6d3ca301 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 diff --git a/packages/media-utils/src/utils/types.ts b/packages/media-utils/src/utils/types.ts index c91d4c67cfc466..c4c6882ea2532e 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 1bc861cfb3b607..ff3f718076512b 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 5a5fb3f39fa183..1ac08a71550ff1 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/rich-text/src/store/selectors.js b/packages/rich-text/src/store/selectors.js index df87c6a99211a2..16572e301c1dba 100644 --- a/packages/rich-text/src/store/selectors.js +++ b/packages/rich-text/src/store/selectors.js @@ -75,7 +75,7 @@ export const getFormatTypes = createSelector( * }; * ``` * - * @return {Object?} Format type. + * @return {?Object} Format type. */ export function getFormatType( state, name ) { return state.formatTypes[ name ]; diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md new file mode 100644 index 00000000000000..e04ce921cdfdc4 --- /dev/null +++ b/packages/upload-media/CHANGELOG.md @@ -0,0 +1,5 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased + +Initial release. diff --git a/packages/upload-media/README.md b/packages/upload-media/README.md new file mode 100644 index 00000000000000..982e59148fe87c --- /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' )`: + +<!-- START TOKEN(Autogenerated actions|src/store/actions.ts) --> + +#### 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. + +<!-- END TOKEN(Autogenerated actions|src/store/actions.ts) --> + +### Selectors + +The following selectors are available on the object returned by `wp.data.select( 'core/upload-media' )`: + +<!-- START TOKEN(Autogenerated selectors|src/store/selectors.ts) --> + +#### 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. + +<!-- END TOKEN(Autogenerated selectors|src/store/selectors.ts) --> diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json new file mode 100644 index 00000000000000..14ae4f77dc5cb9 --- /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 00000000000000..0bc187e6a1d861 --- /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 00000000000000..9a47a5601d33ed --- /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 <WrappedComponent registry={ registry } { ...props } />; + } + + return ( + <RegistryProvider value={ subRegistry }> + <WrappedComponent registry={ subRegistry } { ...props } /> + </RegistryProvider> + ); + }, + '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 00000000000000..d4940d36cd6ae5 --- /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 00000000000000..2c1a43ee1ab67e --- /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 00000000000000..d105c2dba90392 --- /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 00000000000000..5089cb80e4bb46 --- /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 00000000000000..4cc3c3e31ae0e2 --- /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 00000000000000..ad0960cf62f46d --- /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 00000000000000..c74f59ea7a7cf3 --- /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 00000000000000..a4d4ee7b99c781 --- /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 00000000000000..f2cfdbef76df86 --- /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 00000000000000..290a319fcbc1da --- /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 00000000000000..8bcb8c5d63b6a7 --- /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 00000000000000..adb38ab27128e3 --- /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 00000000000000..80b92e4b14c3d1 --- /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 00000000000000..716b7792ef77a4 --- /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 00000000000000..5084e006a2cfa9 --- /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 00000000000000..f308c0d48b6f49 --- /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 00000000000000..6bf968a7643468 --- /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 00000000000000..b26c4571be73fc --- /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 00000000000000..6e2d497472e762 --- /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 00000000000000..156955373bd0da --- /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 00000000000000..e48ae2df6ebcef --- /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 00000000000000..4d5f025ed8cf39 --- /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 00000000000000..31d6af0e7e4a55 --- /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 00000000000000..d2566566862142 --- /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 00000000000000..a83cdcefe5f99a --- /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 00000000000000..d712e9dcdb6966 --- /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 00000000000000..3950ec03887928 --- /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 00000000000000..cc34462b268dda --- /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 00000000000000..858c583561978e --- /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 00000000000000..2d99455d7b60f1 --- /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 00000000000000..3e9fff3edb53e3 --- /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/phpunit/class-gutenberg-hierarchical-sort-test.php b/phpunit/class-gutenberg-hierarchical-sort-test.php new file mode 100644 index 00000000000000..31b78b272a29a2 --- /dev/null +++ b/phpunit/class-gutenberg-hierarchical-sort-test.php @@ -0,0 +1,207 @@ +<?php + +/** + * Test the build_post_ids_to_display function. + * + * @package Gutenberg + */ +class GutenbergHierarchicalSortTest extends WP_UnitTestCase { + + public function test_return_all_post_ids() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * 12 + * - 3 + * -- 6 + * -- 5 + * - 4 + * -- 7 + * 8 + * - 9 + * -- 11 + * - 10 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 12, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 3, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 12, 3, 6, 5, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 12 => 0, + 3 => 1, + 6 => 2, + 5 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } + + public function test_return_orphans() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * - 11 (orphan) + * - 4 (orphan) + * -- 7 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 11, 4, 7 ), $result['post_ids'] ); + $this->assertEquals( + array( + 11 => 1, + 4 => 1, + 7 => 2, + ), + $result['levels'] + ); + } + + public function test_post_with_empty_post_parent_are_considered_top_level() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * 2 + * - 3 + * -- 5 + * -- 6 + * - 4 + * -- 7 + * 8 + * - 9 + * -- 11 + * - 10 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 2, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => '', // Empty post parent, should be considered top-level. + ), + (object) array( + 'ID' => 3, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 2, 3, 5, 6, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 2 => 0, + 3 => 1, + 5 => 2, + 6 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } +} diff --git a/storybook/decorators/with-max-width-wrapper.js b/storybook/decorators/with-max-width-wrapper.js index ff979b93f213bf..84fb73f20b68f7 100644 --- a/storybook/decorators/with-max-width-wrapper.js +++ b/storybook/decorators/with-max-width-wrapper.js @@ -3,15 +3,12 @@ */ import styled from '@emotion/styled'; -/** - * A Storybook decorator to wrap a story in a div applying a max width and - * padding. This can be used to simulate real world constraints on components - * such as being located within the WordPress editor sidebars. - */ - -const Wrapper = styled.div` - max-width: 248px; -`; +const maxWidthWrapperMap = { + none: 0, + 'wordpress-sidebar': 248, + 'small-container': 600, + 'large-container': 960, +}; const Indicator = styled.div` display: flex; @@ -27,14 +24,19 @@ const Indicator = styled.div` `; export const WithMaxWidthWrapper = ( Story, context ) => { - if ( context.globals.maxWidthWrapper === 'none' ) { + /** + * A Storybook decorator to wrap a story in a div applying a max width. + * This can be used to simulate real world constraints on components + * such as being located within the WordPress editor sidebars. + */ + const maxWidth = maxWidthWrapperMap[ context.globals.maxWidthWrapper ]; + if ( ! maxWidth ) { return <Story { ...context } />; } - return ( - <Wrapper> + <div style={ { maxWidth } }> <Story { ...context } /> - <Indicator>Max-Width Wrapper - 248px</Indicator> - </Wrapper> + <Indicator>{ `Max-Width Wrapper - ${ maxWidth }px` }</Indicator> + </div> ); }; diff --git a/storybook/preview.js b/storybook/preview.js index b29fceec846ffe..b74640d9bcfbcf 100644 --- a/storybook/preview.js +++ b/storybook/preview.js @@ -86,6 +86,8 @@ export const globalTypes = { items: [ { value: 'none', title: 'None' }, { value: 'wordpress-sidebar', title: 'WP Sidebar' }, + { value: 'small-container', title: 'Small container' }, + { value: 'large-container', title: 'Large container' }, ], }, }, @@ -106,6 +108,9 @@ export const parameters = { sort: 'requiredFirst', }, docs: { + controls: { + sort: 'requiredFirst', + }, // Flips the order of the description and the primary component story // so the component is always visible before the fold. page: () => ( diff --git a/storybook/stories/foundations/layout.mdx b/storybook/stories/foundations/layout.mdx index abc0c7c4f6947f..578f4e8b66e428 100644 --- a/storybook/stories/foundations/layout.mdx +++ b/storybook/stories/foundations/layout.mdx @@ -1,4 +1,4 @@ -import { Meta } from '@storybook/addon-docs/blocks'; +import { Meta } from '@storybook/blocks'; import areas from './static/areas.svg'; import pageLayoutExample1 from './static/page-layout-example-1.svg'; import pageLayoutExample2 from './static/page-layout-example-2.svg'; @@ -27,32 +27,34 @@ At the highest level admin pages are comprised of _areas_, that can be arranged Areas can be combined in different ways depending on the use case. Here are some examples. <table> - <tr> - <td style={{verticalAlign: 'top', width: '50%'}}> - #### Sidebar, Content Frame and Preview Frame - <img src={ pageLayoutExample1 } alt="Diagram illustrating an example of the 'Sidebar, Content Frame and Preview Frame' arrangement" width="100%" /> - - A demonstration of this arrangement can be found in the Styles section of the Site Editor, and in the Pages and Templates sections when List layout is selected. - </td> - <td style={{verticalAlign: 'top', width: '50%'}}> - #### Sidebar and Preview Frame - <img src={ pageLayoutExample2 } alt="Diagram illustrating an example of the 'Sidebar and Preview Frame' arrangement" width="100%" /> - - A demonstration of this arrangement can be found in the Design section. - </td> - </tr> - <tr> - <td style={{verticalAlign: 'top', width: '50%'}}> - #### Sidebar and Content Frame - <img src={ pageLayoutExample3 } alt="Diagram illustrating an example of the 'Sidebar and Content Frame' arrangement" width="100%" /> - - A demonstration of this arrangement can be found in the Patterns and Templates sections of the Site Editor, or in the Pages section when Table or Grid layout are selected. - </td> - <td style={{verticalAlign: 'top', width: '50%'}}> - #### Sidebar and multiple Content Frames - <img src={ pageLayoutExample4 } alt="Diagram illustrating an example of the 'Sidebar and multiple Content Frames' arrangement" width="100%" /> - - Multiple content frames can be utilised as required. - </td> - </tr> + <tbody> + <tr> + <td style={{verticalAlign: 'top', width: '50%'}}> + #### Sidebar, Content Frame and Preview Frame + <img src={ pageLayoutExample1 } alt="Diagram illustrating an example of the 'Sidebar, Content Frame and Preview Frame' arrangement" width="100%" /> + + A demonstration of this arrangement can be found in the Styles section of the Site Editor, and in the Pages and Templates sections when List layout is selected. + </td> + <td style={{verticalAlign: 'top', width: '50%'}}> + #### Sidebar and Preview Frame + <img src={ pageLayoutExample2 } alt="Diagram illustrating an example of the 'Sidebar and Preview Frame' arrangement" width="100%" /> + + A demonstration of this arrangement can be found in the Design section. + </td> + </tr> + <tr> + <td style={{verticalAlign: 'top', width: '50%'}}> + #### Sidebar and Content Frame + <img src={ pageLayoutExample3 } alt="Diagram illustrating an example of the 'Sidebar and Content Frame' arrangement" width="100%" /> + + A demonstration of this arrangement can be found in the Patterns and Templates sections of the Site Editor, or in the Pages section when Table or Grid layout are selected. + </td> + <td style={{verticalAlign: 'top', width: '50%'}}> + #### Sidebar and multiple Content Frames + <img src={ pageLayoutExample4 } alt="Diagram illustrating an example of the 'Sidebar and multiple Content Frames' arrangement" width="100%" /> + + Multiple content frames can be utilised as required. + </td> + </tr> + </tbody> </table> diff --git a/test/e2e/specs/editor/blocks/buttons.spec.js b/test/e2e/specs/editor/blocks/buttons.spec.js index d6b0a0a15c4ea2..c7fdc18429e11e 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(); diff --git a/test/e2e/specs/interactivity/directive-each.spec.ts b/test/e2e/specs/interactivity/directive-each.spec.ts index 511b38e7ddbb8b..3c015e63fe4bc1 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(); + } + } ); + } } ); diff --git a/test/e2e/specs/site-editor/homepage-settings.spec.js b/test/e2e/specs/site-editor/homepage-settings.spec.js index d53130af23ac8b..e80e14830364ce 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(); } ); } ); diff --git a/test/e2e/specs/site-editor/template-registration.spec.js b/test/e2e/specs/site-editor/template-registration.spec.js index ed89c7d18bf3fb..2960367fc32ef1 100644 --- a/test/e2e/specs/site-editor/template-registration.spec.js +++ b/test/e2e/specs/site-editor/template-registration.spec.js @@ -100,8 +100,7 @@ test.describe( 'Block template registration', () => { page, } ) => { // Create a post. - await admin.visitAdminPage( '/post-new.php' ); - await page.getByLabel( 'Close', { exact: true } ).click(); + await admin.createNewPost(); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'User-created post.' }, @@ -128,7 +127,7 @@ test.describe( 'Block template registration', () => { blockTemplateRegistrationUtils, } ) => { // Create a post. - await admin.visitAdminPage( '/post-new.php' ); + await admin.createNewPost(); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'User-created post.' }, diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index c09cfe3c67b444..5a0c7f0e952116 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -395,7 +395,7 @@ test.describe( 'Site Editor Performance', () => { await requestUtils.activateTheme( 'twentytwentyfour' ); } ); - const perPage = 20; + const perPage = 9; test( 'Run the test', async ( { page, admin, requestUtils } ) => { await Promise.all( diff --git a/test/unit/config/global-mocks.js b/test/unit/config/global-mocks.js index 8db2c180fadf3a..ce64f03b514be8 100644 --- a/test/unit/config/global-mocks.js +++ b/test/unit/config/global-mocks.js @@ -3,7 +3,6 @@ */ import { TextDecoder, TextEncoder } from 'node:util'; import { Blob as BlobPolyfill, File as FilePolyfill } from 'node:buffer'; -import 'core-js/stable/structured-clone'; jest.mock( '@wordpress/compose', () => { return { @@ -50,6 +49,3 @@ if ( ! global.TextEncoder ) { // Override jsdom built-ins with native node implementation. global.Blob = BlobPolyfill; global.File = FilePolyfill; - -// Polyfill structuredClone for jsdom. -global.structuredClone = structuredClone; diff --git a/tsconfig.json b/tsconfig.json index 1010054ea512ea..93d0bd976dd005 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" },