diff --git a/.github/workflows/build-plugin-zip.yml b/.github/workflows/build-plugin-zip.yml index ce830c04f651d8..0f813267b586bd 100644 --- a/.github/workflows/build-plugin-zip.yml +++ b/.github/workflows/build-plugin-zip.yml @@ -72,7 +72,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: token: ${{ secrets.GUTENBERG_TOKEN }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -168,13 +168,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ needs.bump-version.outputs.release_branch || github.ref }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Use desired version of Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: '.nvmrc' check-latest: true @@ -225,7 +225,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: fetch-depth: 2 ref: ${{ needs.bump-version.outputs.release_branch }} @@ -314,14 +314,14 @@ jobs: if: ${{ endsWith( needs.bump-version.outputs.new_version, '-rc.1' ) }} steps: - name: Checkout (for CLI) - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: path: main ref: trunk show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Checkout (for publishing) - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: path: publish # Later, we switch this branch in the script that publishes packages. @@ -336,7 +336,7 @@ jobs: git config user.email gutenberg@wordpress.org - name: Setup Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: 'main/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 499a2c020255cb..e24b30eea7ba7c 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -37,13 +37,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: fetch-depth: 1 show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Use desired version of Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: '.nvmrc' check-latest: true diff --git a/.github/workflows/check-backport-changelog.yml b/.github/workflows/check-backport-changelog.yml index 366bad9fdbc247..cf07b1a3936b97 100644 --- a/.github/workflows/check-backport-changelog.yml +++ b/.github/workflows/check-backport-changelog.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest if: ${{ !contains(github.event.pull_request.labels.*.name, 'No Core Sync Required') && !contains(github.event.pull_request.labels.*.name, 'Backport from WordPress Core') }} steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/check-components-changelog.yml b/.github/workflows/check-components-changelog.yml index 1f6863b4a486e1..40fbfe22bea568 100644 --- a/.github/workflows/check-components-changelog.yml +++ b/.github/workflows/check-components-changelog.yml @@ -22,7 +22,7 @@ jobs: - name: 'Get PR commit count' run: echo "PR_COMMIT_COUNT=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_ENV}" - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/cherry-pick-wp-release.yml b/.github/workflows/cherry-pick-wp-release.yml index b43b0cc267314d..11688a7cfba980 100644 --- a/.github/workflows/cherry-pick-wp-release.yml +++ b/.github/workflows/cherry-pick-wp-release.yml @@ -70,7 +70,7 @@ jobs: - name: Checkout repository if: env.cherry_pick == 'true' - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: token: ${{ secrets.GUTENBERG_TOKEN }} fetch-depth: 0 diff --git a/.github/workflows/create-block.yml b/.github/workflows/create-block.yml index 0de1b9ee6566ae..d20b3e353c31e4 100644 --- a/.github/workflows/create-block.yml +++ b/.github/workflows/create-block.yml @@ -24,7 +24,7 @@ jobs: os: ['macos-latest', 'ubuntu-latest', 'windows-latest'] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 99166989cd68ca..bbf033222a4b31 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -27,7 +27,7 @@ jobs: totalParts: [8] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -102,7 +102,7 @@ jobs: steps: # Checkout defaults to using the branch which triggered the event, which # isn't necessarily `trunk` (e.g. in the case of a merge). - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: trunk show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/enforce-pr-labels.yml b/.github/workflows/enforce-pr-labels.yml index 093eb9a325e365..7493459a6ff35c 100644 --- a/.github/workflows/enforce-pr-labels.yml +++ b/.github/workflows/enforce-pr-labels.yml @@ -12,7 +12,7 @@ jobs: with: mode: exactly count: 1 - labels: '[Type] Automated Testing, [Type] Breaking Change, [Type] Bug, [Type] Build Tooling, [Type] Code Quality, [Type] Copy, [Type] Developer Documentation, [Type] Enhancement, [Type] Experimental, [Type] Feature, [Type] New API, [Type] Task, [Type] Technical Prototype, [Type] Performance, [Type] Project Management, [Type] Regression, [Type] Security, [Type] WP Core Ticket, Backport from WordPress Core' + labels: '[Type] Automated Testing, [Type] Breaking Change, [Type] Bug, [Type] Build Tooling, [Type] Code Quality, [Type] Copy, [Type] Developer Documentation, [Type] Enhancement, [Type] Experimental, [Type] Feature, [Type] New API, [Type] Task, [Type] Technical Prototype, [Type] Performance, [Type] Project Management, [Type] Regression, [Type] Security, [Type] WP Core Ticket, Backport from WordPress Core, Gutenberg Plugin' add_comment: true message: "**Warning: Type of PR label mismatch**\n\n To merge this PR, it requires {{ errorString }} {{ count }} label indicating the type of PR. Other labels are optional and not being checked here. \n- **Type-related labels to choose from**: {{ provided }}.\n- **Labels found**: {{ applied }}.\n\nRead more about [Type labels in Gutenberg](https://github.com/WordPress/gutenberg/labels?q=type). Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task." exit_type: failure diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 8cc11b9bd913bb..4715e1e09c2b83 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -6,7 +6,7 @@ jobs: name: 'Validation' runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - uses: gradle/wrapper-validation-action@v3 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 98615b93b8a176..9c4bee3af473c8 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -33,7 +33,7 @@ jobs: WP_ARTIFACTS_PATH: ${{ github.workspace }}/artifacts steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml index a24e5012474025..66f8130ece2f06 100644 --- a/.github/workflows/publish-npm-packages.yml +++ b/.github/workflows/publish-npm-packages.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout (for CLI) if: ${{ github.event.inputs.release_type != 'wp' }} - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: path: cli ref: trunk @@ -39,7 +39,7 @@ jobs: - name: Checkout (for publishing) if: ${{ github.event.inputs.release_type != 'wp' }} - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: path: publish # Later, we switch this branch in the script that publishes packages. @@ -49,7 +49,7 @@ jobs: - name: Checkout (for publishing WP major version) if: ${{ github.event.inputs.release_type == 'wp' && github.event.inputs.wp_version }} - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: path: publish ref: wp/${{ github.event.inputs.wp_version }} @@ -67,7 +67,7 @@ jobs: - name: Setup Node.js if: ${{ github.event.inputs.release_type != 'wp' }} - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: 'cli/.nvmrc' registry-url: 'https://registry.npmjs.org' @@ -75,7 +75,7 @@ jobs: - name: Setup Node.js (for WP major version) if: ${{ github.event.inputs.release_type == 'wp' && github.event.inputs.wp_version }} - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: 'publish/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/pull-request-automation.yml b/.github/workflows/pull-request-automation.yml index 996bb1667ada57..2006eafd81cc7d 100644 --- a/.github/workflows/pull-request-automation.yml +++ b/.github/workflows/pull-request-automation.yml @@ -12,13 +12,13 @@ jobs: steps: # Checkout defaults to using the branch which triggered the event, which # isn't necessarily `trunk` (e.g. in the case of a merge). - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: trunk show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Use desired version of Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: '.nvmrc' check-latest: true diff --git a/.github/workflows/rnmobile-android-runner.yml b/.github/workflows/rnmobile-android-runner.yml index 2c3998c2952808..917ee6144087e6 100644 --- a/.github/workflows/rnmobile-android-runner.yml +++ b/.github/workflows/rnmobile-android-runner.yml @@ -23,7 +23,7 @@ jobs: steps: - name: checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/rnmobile-ios-runner.yml b/.github/workflows/rnmobile-ios-runner.yml index 2926e494b09f89..cf065ad1cdf7db 100644 --- a/.github/workflows/rnmobile-ios-runner.yml +++ b/.github/workflows/rnmobile-ios-runner.yml @@ -23,11 +23,11 @@ jobs: native-test-name: [gutenberg-editor-rendering] steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - - uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0 + - uses: ruby/setup-ruby@c04af2bb7258bb6a03df1d3c1865998ac9390972 # v1.194.0 with: # `.ruby-version` file location working-directory: packages/react-native-editor/ios diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml index 1efd248bf7f306..1af2bb0ec79275 100644 --- a/.github/workflows/static-checks.yml +++ b/.github/workflows/static-checks.yml @@ -22,12 +22,12 @@ jobs: if: ${{ github.repository == 'WordPress/gutenberg' || github.event_name == 'pull_request' }} steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Use desired version of Node.js - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version-file: '.nvmrc' check-latest: true diff --git a/.github/workflows/storybook-pages.yml b/.github/workflows/storybook-pages.yml index 65dd46b3a76108..83f7fdb96f9262 100644 --- a/.github/workflows/storybook-pages.yml +++ b/.github/workflows/storybook-pages.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: trunk show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/sync-backport-changelog.yml b/.github/workflows/sync-backport-changelog.yml index bbc5663cf715be..b71d9440c38a1f 100644 --- a/.github/workflows/sync-backport-changelog.yml +++ b/.github/workflows/sync-backport-changelog.yml @@ -20,7 +20,7 @@ jobs: ) steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: fetch-depth: 2 # Fetch the last two commits to compare changes - name: Check for changes in backport-changelog diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c0f70070908c1c..bfa35492589a48 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -70,7 +70,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -121,7 +121,7 @@ jobs: name: Build JavaScript assets for PHP unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -135,7 +135,9 @@ jobs: uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 with: name: build-assets - path: ./build/ + path: | + ./build/ + ./build-module/ test-php: name: PHP ${{ matrix.php }}${{ matrix.multisite && ' multisite' || '' }}${{ matrix.wordpress != '' && format( ' (WP {0}) ', matrix.wordpress ) || '' }} on ubuntu-latest @@ -153,6 +155,7 @@ jobs: - '8.0' - '8.1' - '8.2' + - '8.3' multisite: [false, true] wordpress: [''] # Latest WordPress version. include: @@ -161,7 +164,7 @@ jobs: wordpress: 'previous major version' - php: '7.4' wordpress: 'previous major version' - - php: '8.2' + - php: '8.3' wordpress: 'previous major version' env: @@ -170,7 +173,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -195,12 +198,6 @@ jobs: - name: Override PHP version in composer.json run: composer config platform.php ${{ matrix.php }} - # The spatie/phpunit-watcher package is not compatible with PHP < 7.2. - # It must be removed before running the tests. - - name: Remove incompatible Composer packages - if: ${{ matrix.php < '7.2' }} - run: composer remove spatie/phpunit-watcher --dev --no-update - # Since Composer dependencies are installed using `composer update` and no lock file is in version control, # passing a custom cache suffix ensures that the cache is flushed at least once per week. - name: Install Composer dependencies @@ -212,7 +209,6 @@ jobs: uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: build-assets - path: ./build - name: Docker debug information run: | @@ -282,7 +278,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} @@ -352,7 +348,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} diff --git a/.github/workflows/upload-release-to-plugin-repo.yml b/.github/workflows/upload-release-to-plugin-repo.yml index 81a9c4739ac19b..d09e2af3dd2135 100644 --- a/.github/workflows/upload-release-to-plugin-repo.yml +++ b/.github/workflows/upload-release-to-plugin-repo.yml @@ -96,7 +96,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 with: ref: ${{ matrix.branch }} token: ${{ secrets.GUTENBERG_TOKEN }} diff --git a/backport-changelog/6.7/7360.md b/backport-changelog/6.7/7360.md new file mode 100644 index 00000000000000..b2fb8efd624b93 --- /dev/null +++ b/backport-changelog/6.7/7360.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7360 + +* https://github.com/WordPress/gutenberg/pull/65460 diff --git a/changelog.txt b/changelog.txt index dca31f9afc622e..b802a88a14202c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ == Changelog == -= 19.3.0-rc.1 = += 19.3.0 = ## Changelog @@ -17,12 +17,12 @@ - Media placeholders: Add "drag" to the text. ([65149](https://github.com/WordPress/gutenberg/pull/65149)) - Restore: Move to trash button in Document settings. ([65087](https://github.com/WordPress/gutenberg/pull/65087)) - Inspector Controls: Use custom block name in inspector controls when available. ([65398](https://github.com/WordPress/gutenberg/pull/65398)) -- Plugin: Don't force iframe editor when gutenberg plugin and block theme are enabled. ([65372](https://github.com/WordPress/gutenberg/pull/65372)) - Icons: Adds bell and bell-unread icons. ([65324](https://github.com/WordPress/gutenberg/pull/65324)) - Editor topbar: Reorder the actions on the right. ([65163](https://github.com/WordPress/gutenberg/pull/65163)) - Patterns: Add opt out preference to the 'Choose a Pattern' modal when adding a page. ([65026](https://github.com/WordPress/gutenberg/pull/65026)) - Locked Templates: Blocks with contentOnly locking should not be transformable. ([64917](https://github.com/WordPress/gutenberg/pull/64917)) - Block Locking: Add border to Replace item in content only image toolbar. ([64849](https://github.com/WordPress/gutenberg/pull/64849)) +- DataViews: Improve UX of bundled views for Pages. ([65295](https://github.com/WordPress/gutenberg/pull/65295)) #### Components - Styling: Apply elevation scale in components package. ([65159](https://github.com/WordPress/gutenberg/pull/65159)) @@ -50,7 +50,6 @@ #### Block Editor - Link Editing: Automatically add tel to phone number when linking URL. ([64865](https://github.com/WordPress/gutenberg/pull/64865)) -thub.com/WordPress/gutenberg/pull/65300)) - Drag and Drop: When dragging a mix of video, audio, and image blocks, create individual blocks as appropriate. ([65144](https://github.com/WordPress/gutenberg/pull/65144)) - URLInput: Replace input with InputControl. ([65158](https://github.com/WordPress/gutenberg/pull/65158)) - Normalize block inspector controls spacing. ([64526](https://github.com/WordPress/gutenberg/pull/64526)) @@ -70,10 +69,6 @@ thub.com/WordPress/gutenberg/pull/65300)) - Refactor site background controls and move site global styles into Background group. ([65304](https://github.com/WordPress/gutenberg/pull/65304)) - Spacing control: Replace sides dropdwon with link button. ([65193](https://github.com/WordPress/gutenberg/pull/65193)) -#### Data Views -- DataViews Sidebar: Display item count on DataViews sidebar. ([65223](https://github.com/WordPress/gutenberg/pull/65223)) -- DataViews: Improve UX of bundled views for Pages. ([65295](https://github.com/WordPress/gutenberg/pull/65295)) - #### Interactivity API - Refactor context proxies. ([64713](https://github.com/WordPress/gutenberg/pull/64713)) - Update: Rephrase "Force page reload" and move to Advanced. ([65081](https://github.com/WordPress/gutenberg/pull/65081)) @@ -86,6 +81,7 @@ thub.com/WordPress/gutenberg/pull/65300)) - Add @wordpress/fields package. - Introduce the package. ([65230](https://github.com/WordPress/gutenberg/pull/65230)) - Make the package private. ([65269](https://github.com/WordPress/gutenberg/pull/65269)) +- Interactivity API: Add `getServerState()` and `getServerContext()`. ([65151](https://github.com/WordPress/gutenberg/pull/65151)) ### Bug Fixes @@ -97,7 +93,6 @@ thub.com/WordPress/gutenberg/pull/65300)) - Fix: Moving a page to the trash on the site editor does not goes back to the pages list. ([65119](https://github.com/WordPress/gutenberg/pull/65119)) - Fix: Moving the last page item to the the trash causes a crash. ([65236](https://github.com/WordPress/gutenberg/pull/65236)) - Preferences: Fix back button on mobile. ([65141](https://github.com/WordPress/gutenberg/pull/65141)) -- Revert "Don't force iframe editor when gutenberg plugin and block the me are enabled (#65372)". ([65402](https://github.com/WordPress/gutenberg/pull/65402)) - Post Summary Panel: Restore `height:Auto` for toggle buttons. ([65362](https://github.com/WordPress/gutenberg/pull/65362)) - Fix Tabs styling in Font Library modal. ([65330](https://github.com/WordPress/gutenberg/pull/65330)) - E2E: Change deprecated social icons for standard in end-to-end. ([65312](https://github.com/WordPress/gutenberg/pull/65312)) @@ -120,14 +115,17 @@ thub.com/WordPress/gutenberg/pull/65300)) - Fix: Embed blocks: Figcaption inserted via toolbar not nested within figure element - #64960. ([64970](https://github.com/WordPress/gutenberg/pull/64970)) - Image cropping: Skip making an API request if there are no changes to apply. ([65384](https://github.com/WordPress/gutenberg/pull/65384)) - Comments Pagination: Pass the comments query `paged` arg to functions `get_next_comments_link` and `get_previous_comments_link`. ([63698](https://github.com/WordPress/gutenberg/pull/63698)) -- Query Loop: Default to querying posts when on singular content. ([65067](https://github.com/WordPress/gutenberg/pull/65067)) +- Query Loop + - Default to querying posts when on singular content. ([65067](https://github.com/WordPress/gutenberg/pull/65067)) + - Remove is_singular() check and fix test. ([65483](https://github.com/WordPress/gutenberg/pull/65483)) + - Format controls: Fix JavaScript error. ([65551](https://github.com/WordPress/gutenberg/pull/65551)) #### Block Editor - Inserter: Fix loading indicator for reusable blocks. ([64839](https://github.com/WordPress/gutenberg/pull/64839)) - Normalize spacing in Layout hook controls. ([65132](https://github.com/WordPress/gutenberg/pull/65132)) - Pattern Inserter: Fix pattern list overflow. ([65192](https://github.com/WordPress/gutenberg/pull/65192)) - Remove reset styles RTL from the iframe. ([65150](https://github.com/WordPress/gutenberg/pull/65150)) -- Revert "Block Insertion: Clear the insertion point when selecting a dā€¦. ([65208](https://github.com/WordPress/gutenberg/pull/65208)) +- Revert "Block Insertion: Clear the insertion point when selecting a different block or clearing block selection (https://github.com/WordPress/gutenberg/pull/64048)" ([65208](https://github.com/WordPress/gutenberg/pull/65208)) #### Components - BoxControl: Unify input filed width whether linked or not. ([65348](https://github.com/WordPress/gutenberg/pull/65348)) @@ -137,8 +135,10 @@ thub.com/WordPress/gutenberg/pull/65300)) #### Block bindings - Fix empty strings placeholders in post meta bindings. ([65089](https://github.com/WordPress/gutenberg/pull/65089)) -- Prioritize existing `placeholder` over `bindingsPlaceholder`. ([65154](https://github.com/WordPress/gutenberg/pull/65154)) -- Revert "Block Bindings: Prioritize existing `placeholder` over `bindingsPlaceholder`". ([65190](https://github.com/WordPress/gutenberg/pull/65190)) +- Remove key fallback in bindings get values and rely on source label. ([65517](https://github.com/WordPress/gutenberg/pull/65517)) +- Fix passing bindings context to `canUserEditValue`. ([65599](https://github.com/WordPress/gutenberg/pull/65599)) +- Prioritize existing placeholder over bindingsPlaceholder. ([65220](https://github.com/WordPress/gutenberg/pull/65220)) +- Only use `canUserEditValue` when `setValues` is defined. ([65565](https://github.com/WordPress/gutenberg/pull/65566)) #### Zoom Out - Force device type to Desktop whenever zoom out is invoked. ([64476](https://github.com/WordPress/gutenberg/pull/64476)) @@ -150,7 +150,6 @@ thub.com/WordPress/gutenberg/pull/65300)) - A11y: Add script-module. ([65101](https://github.com/WordPress/gutenberg/pull/65101)) - Interactivity API: Use a11y Script Module in Gutenberg. ([65123](https://github.com/WordPress/gutenberg/pull/65123)) - Script Modules API: Print script module live regions HTML in page HTML. ([65380](https://github.com/WordPress/gutenberg/pull/65380)) -- Post Editor: Support keyboard resizing of meta boxes pane. ([65325](https://github.com/WordPress/gutenberg/pull/65325)) - DatePicker: Better hover/focus styles. ([65117](https://github.com/WordPress/gutenberg/pull/65117)) - Form Input: Don't use `flex-direction: Row-reverse` for checkbox field. ([64232](https://github.com/WordPress/gutenberg/pull/64232)) - Navigation Menus: Remove Warning and add notice for Navigation. ([63921](https://github.com/WordPress/gutenberg/pull/63921)) @@ -158,6 +157,11 @@ thub.com/WordPress/gutenberg/pull/65300)) - Block Editor: Fix accessibility of the hooked blocks toggles. ([63133](https://github.com/WordPress/gutenberg/pull/63133)) +#### Post Editor +- Support keyboard resizing of meta boxes pane. ([65325](https://github.com/WordPress/gutenberg/pull/65325)) +- Swap position of the Pre-publish checks buttons. ([65317](https://github.com/WordPress/gutenberg/pull/65317)) + + ### Performance - Core Data: Batch remaining actions in resolvers. ([65176](https://github.com/WordPress/gutenberg/pull/65176)) @@ -199,7 +203,7 @@ thub.com/WordPress/gutenberg/pull/65300)) - Fix: Replace remaining 40px default size violations [Block Editor 1]. ([65034](https://github.com/WordPress/gutenberg/pull/65034)) - BoxControl - Add lint rule for 40px size prop usage. ([65341](https://github.com/WordPress/gutenberg/pull/65341)) - - DimensionsPanel: Apply 40px default size to UI when no spacing preset is available. ([65300](https://gi + - DimensionsPanel: Apply 40px default size to UI when no spacing preset is available. ([65300](https://github.com/WordPress/gutenberg/pull/65300)) - Add `useEvent` and revamped `useResizeObserver` to `@wordpress/compose`. ([64943](https://github.com/WordPress/gutenberg/pull/64943)) - DataViews: Use Dropdown for views configuration dialog. ([65314](https://github.com/WordPress/gutenberg/pull/65314)) - Platform docs: Upgrade dependencies. ([65445](https://github.com/WordPress/gutenberg/pull/65445)) diff --git a/composer.json b/composer.json index 3571377bd58bd7..982a71a975223d 100644 --- a/composer.json +++ b/composer.json @@ -23,14 +23,15 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, "composer/installers": true - } + }, + "lock": false }, "require-dev": { "phpcompatibility/phpcompatibility-wp": "^2.1.3", "wp-coding-standards/wpcs": "^3.0", "sirbrillig/phpcs-variable-analysis": "^2.8", "spatie/phpunit-watcher": "^1.23", - "yoast/phpunit-polyfills": "^1.0", + "yoast/phpunit-polyfills": "^1.1.0", "gutenberg/gutenberg-coding-standards": "@dev" }, "repositories": [ @@ -43,7 +44,7 @@ } ], "require": { - "composer/installers": "~1.0" + "composer/installers": "^1.0 || ^2.0" }, "scripts": { "format": "phpcbf --standard=phpcs.xml.dist --report-summary --report-source", diff --git a/docs/contributors/versions-in-wordpress.md b/docs/contributors/versions-in-wordpress.md index 8c4debb8b696f6..62347f2d644a61 100644 --- a/docs/contributors/versions-in-wordpress.md +++ b/docs/contributors/versions-in-wordpress.md @@ -6,6 +6,7 @@ If anything looks incorrect here, please bring it up in #core-editor in [WordPre | Gutenberg Versions | WordPress Version | | ------------------ | ----------------- | +| 18.6-19.3 | 6.7 | | 17.8-18.5 | 6.6.1 | | 17.8-18.5 | 6.6 | | 16.8-17.7 | 6.5.5 | diff --git a/docs/manifest.json b/docs/manifest.json index e4eba19d99fa29..8387b9079694c0 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -521,6 +521,12 @@ "markdown_source": "../docs/reference-guides/interactivity-api/core-concepts/server-side-rendering.md", "parent": "core-concepts" }, + { + "title": "Using TypeScript", + "slug": "using-typescript", + "markdown_source": "../docs/reference-guides/interactivity-api/core-concepts/using-typescript.md", + "parent": "core-concepts" + }, { "title": "Quick start guide", "slug": "iapi-quick-start-guide", @@ -1092,33 +1098,9 @@ "parent": "components" }, { - "title": "NavigatorBackButton", - "slug": "navigator-back-button", - "markdown_source": "../packages/components/src/navigator/navigator-back-button/README.md", - "parent": "components" - }, - { - "title": "NavigatorButton", - "slug": "navigator-button", - "markdown_source": "../packages/components/src/navigator/navigator-button/README.md", - "parent": "components" - }, - { - "title": "NavigatorProvider", - "slug": "navigator-provider", - "markdown_source": "../packages/components/src/navigator/navigator-provider/README.md", - "parent": "components" - }, - { - "title": "NavigatorScreen", - "slug": "navigator-screen", - "markdown_source": "../packages/components/src/navigator/navigator-screen/README.md", - "parent": "components" - }, - { - "title": "NavigatorToParentButton", - "slug": "navigator-to-parent-button", - "markdown_source": "../packages/components/src/navigator/navigator-to-parent-button/README.md", + "title": "Navigator", + "slug": "navigator", + "markdown_source": "../packages/components/src/navigator/README.md", "parent": "components" }, { @@ -1697,6 +1679,12 @@ "markdown_source": "../packages/eslint-plugin/README.md", "parent": "packages" }, + { + "title": "@wordpress/fields", + "slug": "packages-fields", + "markdown_source": "../packages/fields/README.md", + "parent": "packages" + }, { "title": "@wordpress/format-library", "slug": "packages-format-library", diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index c6552ef431cef8..956e8dd010581a 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -262,7 +262,7 @@ _Returns_ ### getBlockInsertionPoint -Returns the insertion point, the index at which the new inserted block would be placed. Defaults to the last index. +Returns the location of the insertion cue. Defaults to the last index. _Parameters_ @@ -857,15 +857,9 @@ _Returns_ ### hasBlockMovingClientId -Returns whether block moving mode is enabled. - -_Parameters_ - -- _state_ `Object`: Editor state. - -_Returns_ +> **Deprecated** -- `string`: Client Id of moving block. +Returns whether block moving mode is enabled. ### hasDraggedInnerBlock @@ -988,7 +982,7 @@ _Returns_ ### isBlockInsertionPointVisible -Returns true if we should show the block insertion point. +Returns true if the block insertion point is visible. _Parameters_ @@ -1661,11 +1655,13 @@ _Returns_ ### setBlockMovingClientId -Action that enables or disables the block moving mode. +> **Deprecated** -_Parameters_ +Set the block moving client ID. -- _hasBlockMovingClientId_ `string|null`: Enable/Disable block moving mode. +_Returns_ + +- `Object`: Action object. ### setBlockVisibility diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 4fea2c51fa54f3..a4c1a59f0c4231 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -1422,6 +1422,10 @@ _Parameters_ - _value_ `boolean|Object`: Whether the inserter should be opened (true) or closed (false). To specify an insertion point, use an object. - _value.rootClientId_ `string`: The root client ID to insert at. - _value.insertionIndex_ `number`: The index to insert at. +- _value.filterValue_ `string`: A query to filter the inserter results. +- _value.onSelect_ `Function`: A callback when an item is selected. +- _value.tab_ `string`: The tab to open in the inserter. +- _value.category_ `string`: The category to initialize in the inserter. _Returns_ diff --git a/docs/reference-guides/filters/block-filters.md b/docs/reference-guides/filters/block-filters.md index 637cecadf1402b..f1952ef9bf86f8 100644 --- a/docs/reference-guides/filters/block-filters.md +++ b/docs/reference-guides/filters/block-filters.md @@ -294,6 +294,31 @@ Used to filter an individual transform result from block transformation. All of Called immediately after the default parsing of a block's attributes and before validation to allow a plugin to manipulate attribute values in time for validation and/or the initial values rendering of the block in the editor. +The callback function for this filter accepts 4 parameters: +- `blockAttributes` (`Object`): All block attributes. +- `blockType` (`Object`): The block type. +- `innerHTML` (`string`): Raw block content. +- `attributes` (`object`): Known block attributes (from delimiters). + +In the example below, we use the `blocks.getBlockAttributes` filter to lock the position of all paragraph blocks on a page. + +```js +// Our filter function +function lockParagraphs( blockAttributes, blockType, innerHTML, attributes ) { + if('core/paragraph' === blockType.name) { + blockAttributes['lock'] = {move: true} + } + return blockAttributes; +} + +// Add the filter +wp.hooks.addFilter( + 'blocks.getBlockAttributes', + 'my-plugin/lock-paragraphs', + lockParagraphs +); +``` + ### `editor.BlockEdit` Used to modify the block's `edit` component. It receives the original block `BlockEdit` component and returns a new wrapped component. diff --git a/docs/reference-guides/interactivity-api/core-concepts/README.md b/docs/reference-guides/interactivity-api/core-concepts/README.md index f4e6891c4ff165..695a4d622f6c52 100644 --- a/docs/reference-guides/interactivity-api/core-concepts/README.md +++ b/docs/reference-guides/interactivity-api/core-concepts/README.md @@ -7,3 +7,5 @@ This section provides some guides on important concepts and mental models relate 2. **[Understanding global state, local context and derived state](/docs/reference-guides/interactivity-api/core-concepts/undestanding-global-state-local-context-and-derived-state.md):** The guide explains how to effectively use global state, local context, and derived state within the Interactivity API emphasizing the importance of choosing the appropriate state management technique based on the scope and requirements of your data. 3. **[Server-side rendering: Processing directives on the server](/docs/reference-guides/interactivity-api/core-concepts/server-side-rendering.md):** The Interactivity API allows WordPress to use server-side rendering to create interactive and state-aware HTML, smoothly connected with client-side features while maintaining performance and SEO benefits. + +4. **[Using TypeScript](/docs/reference-guides/interactivity-api/core-concepts/using-typescript.md):** This guide will walk you through the process of using TypeScript with Interactivity API stores, covering everything from basic type definitions to advanced techniques for handling complex store structures. diff --git a/docs/reference-guides/interactivity-api/core-concepts/using-typescript.md b/docs/reference-guides/interactivity-api/core-concepts/using-typescript.md new file mode 100644 index 00000000000000..ed0bdd88211d11 --- /dev/null +++ b/docs/reference-guides/interactivity-api/core-concepts/using-typescript.md @@ -0,0 +1,746 @@ +# Using TypeScript + +The Interactivity API provides robust support for TypeScript, enabling developers to build type-safe stores to enhance the development experience with static type checking, improved code completion, and simplified refactoring. This guide will walk you through the process of using TypeScript with Interactivity API stores, covering everything from basic type definitions to advanced techniques for handling complex store structures. + +These are the core principles of TypeScript's interaction with the Interactivity API: + +- **Inferred client types**: When you create a store using the `store` function, TypeScript automatically infers the types of the store's properties (`state`, `actions`, etc.). This means that you can often get away with just writing plain JavaScript objects, and TypeScript will figure out the types for you. +- **Explicit server types**: When dealing with data defined on the server, like local context or the initial values of the global state, you can explicitly define its types to ensure that everything is correctly typed. +- **Mutiple store parts**: Even if your store is split into multiple parts, you can define or infer the types of each part of the store and then merge them into a single type that represents the entire store. +- **Typed external stores**: You can import typed stores from external namespaces, allowing you to use other plugins' functionality with type safety. + +## Installing `@wordpress/interactivity` locally + +If you haven't done so already, you need to install the package `@wordpress/interactivity` locally so TypeScript can use its types in your IDE. You can do this using the following command: + +`npm install @wordpress/interactivity` + +It is also a good practice to keep that package updated. + +## Scaffolding a new typed interactive block + +If you want to explore an example of an interactive block using TypeScript in your local environment, you can use the `@wordpress/create-block-interactive-template`. + +Start by ensuring you have Node.js and `npm` installed on your computer. Review the [Node.js development environment](https://developer.wordpress.org/block-editor/getting-started/devenv/nodejs-development-environment/) guide if not. + +Next, use the [`@wordpress/create-block`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) package and the [`@wordpress/create-block-interactive-template`](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) template to scaffold the block. + +Choose the folder where you want to create the plugin, execute the following command in the terminal from within that folder, and choose the `typescript` variant when asked. + +``` +npx @wordpress/create-block@latest --template @wordpress/create-block-interactive-template +``` + +**Important**: Do not provide a slug in the terminal. Otherwise, `create-block` will not ask you which variant you want to choose and it will select the default non-TypeScript variant by default. + +Finally, you can keep following the instructions in the [Getting Started Guide](https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/iapi-quick-start-guide/) as the rest of the instructions remain the same. + +## Typing the store + +Depending on the structure of your store and your preference, there are three options you can choose from to generate your store's types: + +1. Infer the types from your client store definition. +2. Manually type the server state, but infer the rest from your client store definition. +3. Manually write all the types. + +### 1. Infer the types from your client store definition + +When you create a store using the `store` function, TypeScript automatically infers the types of the store's properties (`state`, `actions`, `callbacks`, etc.). This means that you can often get away with just writing plain JavaScript objects, and TypeScript will figure out the correct types for you. + +Let's start with a basic example of a counter block. We will define the store in the `view.ts` file of the block, which contains the initial global state, an action and a callback. + +```ts +// view.ts +const myStore = store( 'myCounterPlugin', { + state: { + counter: 0, + }, + actions: { + increment() { + myStore.state.counter += 1; + }, + }, + callbacks: { + log() { + console.log( `counter: ${ myStore.state.counter }` ); + }, + }, +} ); +``` + +If you inspect the types of `myStore` using TypeScript, you will see that TypeScript has been able to infer the types correctly. + +```ts +const myStore: { + state: { + counter: number; + }; + actions: { + increment(): void; + }; + callbacks: { + log(): void; + }; +}; +``` + +You can also destructure the `state`, `actions` and `callbacks` properties, and the types will still work correctly. + +```ts +const { state } = store( 'myCounterPlugin', { + state: { + counter: 0, + }, + actions: { + increment() { + state.counter += 1; + }, + }, + callbacks: { + log() { + console.log( `counter: ${ state.counter }` ); + }, + }, +} ); +``` + +In conclusion, inferring the types is useful when you have a simple store defined in a single call to the `store` function and you do not need to type any state that has been initialized on the server. + +### 2. Manually type the server state, but infer the rest from your client store definition + +The global state that is initialized on the server with the `wp_interactivity_state` function doesn't exist on your client store definition and, therefore, needs to be manually typed. But if you don't want to define all the types of your store, you can infer the types of your client store definition and merge them with the types of your server initialized state. + +_Please, visit [the Server-side Rendering guide](/docs/reference-guides/interactivity-api/core-concepts/server-side-rendering.md) to learn more about `wp_interactivity_state` and how directives are processed on the server._ + +Following our previous example, let's move our `counter` state initialization to the server. + +```php +wp_interactivity_state( 'myCounterPlugin', array( + 'counter' => 1, +)); +``` + +Now, let's define the server state types and merge it with the types inferred from the client store definition. + +```ts +// Types the server state. +type ServerState = { + state: { + counter: number; + }; +}; + +// Defines the store in a variable to be able to extract its type later. +const storeDef = { + actions: { + increment() { + state.counter += 1; + }, + }, + callbacks: { + log() { + console.log( `counter: ${ state.counter }` ); + }, + }, +}; + +// Merges the types of the server state and the client store definition. +type Store = ServerState & typeof storeDef; + +// Injects the final types when calling the `store` function. +const { state } = store< Store >( 'myCounterPlugin', storeDef ); +``` + +Alternatively, if you don't mind typing the entire state including both the values defined on the server and the values defined on the client, you can cast the `state` property and let TypeScript infer the rest of the store. + +Let's imagine you have an additional property in the client global state called `product`. + +```ts +type State = { + counter: number; // The server state. + product: number; // The client state. +}; + +const { state } = store( 'myCounterPlugin', { + state: { + product: 2, + } as State, // Casts the entire state manually. + actions: { + increment() { + state.counter * state.product; + }, + }, +} ); +``` + +That's it. Now, TypeScript will infer the types of the `actions` and `callbacks` properties from the store definition, but it will use the type `State` for the `state` property so it contains the correct types from both the client and server definitions. + +In conclusion, this approach is useful when you have a server state that needs to be manually typed, but you still want to infer the types of the rest of the store. + +### 3. Manually write all the types + +If you prefer to define all the types of the store manually instead of letting TypeScript infer them from your client store definition, you can do that too. You simply need to pass them to the `store` function. + +```ts +// Defines the store types. +interface Store { + state: { + counter: number; // Initial server state + }; + actions: { + increment(): void; + }; + callbacks: { + log(): void; + }; +} + +// Pass the types when calling the `store` function. +const { state } = store< Store >( 'myCounterPlugin', { + actions: { + increment() { + state.counter += 1; + }, + }, + callbacks: { + log() { + console.log( `counter: ${ state.counter }` ); + }, + }, +} ); +``` + +That's it! In conclusion, this approach is useful when you want to control all the types of your store and you don't mind writing them by hand. + +## Typing the local context + +The initial local context is defined on the server using the `data-wp-context` directive. + +```html +
...
+``` + +For that reason, you need to define its type manually and pass it to the `getContext` function to ensure the returned properties are correctly typed. + +```ts +// Defines the types of your context. +type MyContext = { + counter: number; +}; + +store( 'myCounterPlugin', { + actions: { + increment() { + // Passes it to the getContext function. + const context = getContext< MyContext >(); + // Now `context` is properly typed. + context.counter += 1; + }, + }, +} ); +``` + +To avoid having to pass the context types over and over, you can also define a typed function and use that function instead of `getContext`. + +```ts +// Defines the types of your context. +type MyContext = { + counter: number; +}; + +// Defines a typed function. You only have to do this once. +const getMyContext = getContext< MyContext >; + +store( 'myCounterPlugin', { + actions: { + increment() { + // Use your typed function. + const context = getMyContext(); + // Now `context` is properly typed. + context.counter += 1; + }, + }, +} ); +``` + +That's it! Now you can access the context properties with the correct types. + +## Typing the derived state + +The derived state is data that is calculated based on the global state or local context. In the client store definition, it is defined using a getter in the `state` object. + +_Please, visit the [Understanding global state, local context and derived state](./undestanding-global-state-local-context-and-derived-state.md) guide to learn more about how derived state works in the Interactivity API._ + +Following our previous example, let's create a derived state that is the double of our counter. + +```ts +type MyContext = { + counter: number; +}; + +const myStore = store( 'myCounterPlugin', { + state: { + get double() { + const { counter } = getContext< MyContext >(); + return counter * 2; + }, + }, + actions: { + increment() { + state.counter += 1; // This type is number. + }, + }, +} ); +``` + +Normally, when the derived state depends on the local context, TypeScript will be able to infer the correct types: + +```ts +const myStore: { + state: { + readonly double: number; + }; + actions: { + increment(): void; + }; +}; +``` + +But when the return value of the derived state depends directly on some part of the global state, TypeScript will not be able to infer the types because it will claim that it has a circular reference. + +For example, in this case, TypeScript cannot infer the type of `state.double` because it depends on `state.counter`, and the type of `state` is not completed until the type of `state.double` is defined, creating a circular reference. + +```ts +const { state } = store( 'myCounterPlugin', { + state: { + counter: 0, + get double() { + // TypeScript can't infer this return type because it depends on `state`. + return state.counter * 2; + }, + }, + actions: { + increment() { + state.counter += 1; // This type is now unknown. + }, + }, +} ); +``` + +In this case, depending on your TypeScript configuration, TypeScript will either warn you about a circular reference or simply add the `any` type to the `state` property. + +However, solving this problem is easy; we simply need to manually provide TypeScript with the return type of that getter. Once we do that, the circular reference disappears, and TypeScript can once again infer all the `state` types. + +```ts +const { state } = store( 'myCounterPlugin', { + state: { + counter: 1, + get double(): number { + return state.counter * 2; + }, + }, + actions: { + increment() { + state.counter += 1; // Correctly inferred! + }, + }, +} ); +``` + +These are now the correct inferred types for the previous store. + +```ts +const myStore: { + state: { + counter: number; + readonly double: number; + }; + actions: { + increment(): void; + }; +}; +``` + +When using `wp_interactivity_state` in the server, remember that you also need to define the initial value of your derived state, like this: + +```php +wp_interactivity_state( 'myCounterPlugin', array( + 'counter' => 1, + 'double' => 2, +)); +``` + +But if you are inferring the types, you don't need to manually define the type of the derived state because it already exists in your client's store definition. + +```ts +// You don't need to type `state.double` here. +type ServerState = { + state: { + counter: number; + }; +}; + +// The `state.double` type is inferred from here. +const storeDef = { + state: { + get double(): number { + return state.counter * 2; + }, + }, + actions: { + increment() { + state.counter += 1; + }, + }, +}; + +// Merges the types of the server state and the client store definition. +type Store = ServerState & typeof storeDef; + +// Injects the final types when calling the `store` function. +const { state } = store< Store >( 'myCounterPlugin', storeDef ); +``` + +That's it! Now you can access the derived state properties with the correct types. + +## Typing asynchronous actions + +Another thing to keep in mind when using TypeScript with the Interactivity API is that asynchronous actions must be defined with generators instead of async functions. + +The reason for using generators in the Interactivity API's asynchronous actions is to be able to restore the scope from the initially triggered action once the asynchronous action continues its execution after yielding. But this is a syntax change only, otherwise, **these functions operate just like regular async functions**, and the inferred types from the `store` function reflect this. + +Following our previous example, let's add an asynchronous action to the store. + +```ts +const { state } = store( 'myCounterPlugin', { + state: { + counter: 0, + get double(): number { + return state.counter * 2; + }, + }, + actions: { + increment() { + state.counter += 1; + }, + *delayedIncrement() { + yield new Promise( ( r ) => setTimeout( r, 1000 ) ); + state.counter += 1; + }, + }, +} ); +``` + +The inferred types for this store are: + +```ts +const myStore: { + state: { + counter: number; + readonly double: number; + }; + actions: { + increment(): void; + // This behaves like a regular async function. + delayedIncrement(): Promise< void >; + }; +}; +``` + +This also means that you can use your async actions in external functions, and TypeScript will correctly use the async function types. + +```ts +const someAsyncFunction = async () => { + // This works fine and it's correctly typed. + await actions.delayedIncrement( 2000 ); +}; +``` + +When you are not inferring types but manually writing the types for your entire store, you can use async function types for your async actions. + +```ts +type Store = { + state: { + counter: number; + readonly double: number; + }; + actions: { + increment(): void; + delayedIncrement(): Promise< void >; // You can use async functions here. + }; +}; +``` + +There's something to keep in mind when when using asynchronous actions. Just like with the derived state, if the asynchronous action needs to return a value and this value directly depends on some part of the global state, TypeScript will not be able to infer the type due to a circular reference. + + ```ts + const { state, actions } = store( 'myCounterPlugin', { + state: { + counter: 0, + }, + actions: { + *delayedReturn() { + yield new Promise( ( r ) => setTimeout( r, 1000 ) ); + return state.counter; // TypeScript can't infer this return type. + }, + }, + } ); + ``` + + In this case, just as we did with the derived state, we must manually type the return value of the generator. + + ```ts + const { state, actions } = store( 'myCounterPlugin', { + state: { + counter: 0, + }, + actions: { + *delayedReturn(): Generator< uknown, number, uknown > { + yield new Promise( ( r ) => setTimeout( r, 1000 ) ); + return state.counter; // Now this is correctly inferred. + }, + }, + } ); + ``` + + That's it! Remember that the return type of a Generator is the second generic argument: `Generator< unknown, ReturnType, unknown >`. + +## Typing stores that are divided into multiple parts + +Sometimes, stores can be divided into different files. This can happen when different blocks share the same namespace, with each block loading the part of the store it needs. + +Let's look at an example of two blocks: + +- `todo-list`: A block that displays a list of todos. +- `add-post-to-todo`: A block that shows a button to add a new todo item to the list with the text "Read {$post_title}". + +First, let's initialize the global and derived state of the `todo-list` block on the server. + +```php + $todos, + 'filter' => 'all', + 'filteredTodos' => $todos, +)); +?> + + +``` + +Now, let's type the server state and add the client store definition. Remember, `filteredTodos` is derived state, so you don't need to type it manually. + +```ts +// todo-list-block/view.ts +type ServerState = { + state: { + todos: string[]; + filter: 'all' | 'completed'; + }; +}; + +const todoList = { + state: { + get filteredTodos(): string[] { + return state.filter === 'completed' + ? state.todos.filter( ( todo ) => todo.includes( 'āœ…' ) ) + : state.todos; + }, + }, + actions: { + addTodo( todo: string ) { + state.todos.push( todo ); + }, + }, +}; + +// Merges the inferred types with the server state types. +export type TodoList = ServerState & typeof todoList; + +// Injects the final types when calling the `store` function. +const { state } = store< TodoList >( 'myTodoPlugin', todoList ); +``` + +So far, so good. Now let's create our `add-post-to-todo` block. + +First, let's add the current post title to the server state. + +```php + get_the_title(), +)); +?> + + +``` + +Now, let's type that server state and add the client store definition. + +```ts +// add-post-to-todo-block/view.ts +type ServerState = { + state: { + postTitle: string; + }; +}; + +const addPostToTodo = { + actions: { + addPostToTodo() { + const todo = `Read: ${ state.postTitle }`.trim(); + if ( ! state.todos.includes( todo ) ) { + actions.addTodo( todo ); + } + }, + }, +}; + +// Merges the inferred types with the server state types. +type Store = ServerState & typeof addPostToTodo; + +// Injects the final types when calling the `store` function. +const { state, actions } = store< Store >( 'myTodoPlugin', addPostToTodo ); +``` + +This works fine in the browser, but TypeScript will complain that, in this block, `state` and `actions` do not include `state.todos` and `actions.addtodo`. + +To fix this, we need to import the `TodoList` type from the `todo-list` block and merge it with the other types. + +```ts +import type { TodoList } from '../todo-list-block/view'; + +// ... + +// Merges the inferred types inferred the server state types. +type Store = TodoList & ServerState & typeof addPostToTodo; +``` + +That's it! Now TypeScript will know that `state.todos` and `actions.addTodo` are available in the `add-post-to-todo` block. + +This approach allows the `add-post-to-todo` block to interact with the existing todo list while maintaining type safety and adding its own functionality to the shared store. + +If you need to use the `add-post-to-todo` types in the `todo-list` block, you simply have to export its types and import them in the other `view.ts` file. + +Finally, if you prefer to define all types manually instead of inferring them, you can define them in a separate file and import that definition into each of your store parts. Here's how you could do that for our todo list example: + +```ts +// types.ts +interface Store { + state: { + todos: string[]; + filter: 'all' | 'completed'; + filtered: string[]; + postTitle: string; + }; + actions: { + addTodo( todo: string ): void; + addPostToTodo(): void; + }; +} + +export default Store; +``` + +```ts +// todo-list-block/view.ts +import type Store from '../types'; + +const { state } = store< Store >( 'myTodoPlugin', { + // Everything is correctly typed here +} ); +``` + +```ts +// add-post-to-todo-block/view.ts +import type Store from '../types'; + +const { state, actions } = store< Store >( 'myTodoPlugin', { + // Everything is correctly typed here +} ); +``` + +This approach allows you to have full control over your types and ensures consistency across all parts of your store. It's particularly useful when you have a complex store structure or when you want to enforce a specific interface across multiple blocks or components. + +## Importing and exporting typed stores + +In the Interactivity API, stores from other namespaces can be accessed using the `store` function. + +Let's go back to our `todo-list` block example, but this time, let's imagine that the `add-post-to-todo` block belongs to a different plugin and therefore will use a different namespace. + +```ts +// Import the store of the `todo-list` block. +const myTodoPlugin = store( 'myTodoPlugin' ); + +store( 'myAddPostToTodoPlugin', { + actions: { + addPostToTodo() { + const todo = `Read: ${ state.postTitle }`.trim(); + if ( ! myTodoPlugin.state.todos.includes( todo ) ) { + myTodoPlugin.actions.addTodo( todo ); + } + }, + }, +} ); +``` + +This works fine in the browser, but TypeScript will complain that `myTodoPlugin.state` and `myTodoPlugin.actions` are not typed. + +To fix that, the `myTodoPlugin` plugin can export the result of calling the `store` function with the correct types, and make that available using a script module. + +```ts +// Export the already typed state and actions. +export const { state, actions } = store< TodoList >( 'myTodoPlugin', { + // ... +} ); +``` + +Now, the `add-post-to-todo` block can import the typed store from the `myTodoPlugin` script module, and it not only ensures that the store will be loaded, but that it also contains the correct types. + +```ts +import { store } from '@wordpress/interactivity'; +import { + state as todoState, + actions as todoActions, +} from 'my-todo-plugin-module'; + +store( 'myAddPostToTodoPlugin', { + actions: { + addPostToTodo() { + const todo = `Read: ${ state.postTitle }`.trim(); + if ( ! todoState.todos.includes( todo ) ) { + todoActions.addTodo( todo ); + } + }, + }, +} ); +``` + +Remember that you will need to declare the `my-todo-plugin-module` script module as a dependency. + +If the other store is optional and you don't want to load it eagerly, a dynamic import can be used instead of a static import. + +```ts +import { store } from '@wordpress/interactivity'; + +store( 'myAddPostToTodoPlugin', { + actions: { + *addPostToTodo() { + const todoPlugin = yield import( 'my-todo-plugin-module' ); + const todo = `Read: ${ state.postTitle }`.trim(); + if ( ! todoPlugin.state.todos.includes( todo ) ) { + todoPlugin.actions.addTodo( todo ); + } + }, + }, +} ); +``` + +## Conclusion + +In this guide, we explored different approaches to typing the Interactivity API stores, from inferring types automatically to manually defining them. We also covered how to handle server-initialized state, local context, and derived state, as well as how to type asynchronous actions. + +Remember that the choice between inferring types and manually defining them depends on your specific needs and the complexity of your store. Whichever approach you choose, TypeScript will help you build better and more reliable interactive blocks. diff --git a/docs/toc.json b/docs/toc.json index 719ffa344e3744..0d4689811b26ec 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -214,6 +214,9 @@ }, { "docs/reference-guides/interactivity-api/core-concepts/server-side-rendering.md": [] + }, + { + "docs/reference-guides/interactivity-api/core-concepts/using-typescript.md": [] } ] }, diff --git a/gutenberg.php b/gutenberg.php index 8dddcfeccd5282..c9f4a8fc580200 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.5 * Requires PHP: 7.2 - * Version: 19.3.0-rc.1 + * Version: 19.4.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/client-assets.php b/lib/client-assets.php index 62e874d6b06c82..2343530e5595a7 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -601,6 +601,56 @@ function gutenberg_register_vendor_scripts( $scripts ) { } add_action( 'wp_default_scripts', 'gutenberg_register_vendor_scripts' ); +/** + * Registers or re-registers Gutenberg Script Modules. + * + * Script modules that are registered by Core will be re-registered by Gutenberg. + * + * @since 19.3.0 + */ +function gutenberg_default_script_modules() { + /* + * Expects multidimensional array like: + * + * 'interactivity/index.min.js' => array('dependencies' => array(ā€¦), 'version' => 'ā€¦'), + * 'interactivity/debug.min.js' => array('dependencies' => array(ā€¦), 'version' => 'ā€¦'), + * 'interactivity-router/index.min.js' => ā€¦ + */ + $assets = include gutenberg_dir_path() . '/build-module/assets.php'; + + foreach ( $assets as $file_name => $script_module_data ) { + /* + * Build the WordPress Script Module ID from the file name. + * Prepend `@wordpress/` and remove extensions and `/index` if present: + * - interactivity/index.min.js => @wordpress/interactivity + * - interactivity/debug.min.js => @wordpress/interactivity/debug + * - block-library/query/view.js => @wordpress/block-library/query/view + */ + $script_module_id = '@wordpress/' . preg_replace( '~(?:/index)?\.min\.js$~D', '', $file_name, 1 ); + switch ( $script_module_id ) { + /* + * Interactivity exposes two entrypoints, "/index" and "/debug". + * "/debug" should replalce "/index" in devlopment. + */ + case '@wordpress/interactivity/debug': + if ( ! SCRIPT_DEBUG ) { + continue 2; + } + $script_module_id = '@wordpress/interactivity'; + break; + case '@wordpress/interactivity': + if ( SCRIPT_DEBUG ) { + continue 2; + } + break; + } + + $path = gutenberg_url( "build-module/{$file_name}" ); + wp_register_script_module( $script_module_id, $path, $script_module_data['dependencies'], $script_module_data['version'] ); + } +} +remove_action( 'wp_default_scripts', 'wp_default_script_modules' ); +add_action( 'wp_default_scripts', 'gutenberg_default_script_modules' ); /* * Always remove the Core action hook while gutenberg_enqueue_stored_styles() exists to avoid styles being printed twice. diff --git a/lib/compat/wordpress-6.7/script-modules.php b/lib/compat/wordpress-6.7/script-modules.php index 0a440ec81688d2..2282a3d4bd5acd 100644 --- a/lib/compat/wordpress-6.7/script-modules.php +++ b/lib/compat/wordpress-6.7/script-modules.php @@ -102,3 +102,46 @@ function () { }, 20 ); + +/** + * Prints HTML for the a11y Script Module. + * + * a11y relies on some DOM elements to use as ARIA live regions. + * Ideally, these elements are part of the initial HTML of the page + * so that accessibility tools can find them and observe updates. + */ +function gutenberg_a11y_script_module_html() { + $a11y_module_available = false; + + $get_marked_for_enqueue = new ReflectionMethod( 'WP_Script_Modules', 'get_marked_for_enqueue' ); + $get_marked_for_enqueue->setAccessible( true ); + $get_import_map = new ReflectionMethod( 'WP_Script_Modules', 'get_import_map' ); + $get_import_map->setAccessible( true ); + + foreach ( array_keys( $get_marked_for_enqueue->invoke( wp_script_modules() ) ) as $id ) { + if ( '@wordpress/a11y' === $id ) { + $a11y_module_available = true; + break; + } + } + if ( ! $a11y_module_available ) { + foreach ( array_keys( $get_import_map->invoke( wp_script_modules() )['imports'] ) as $id ) { + if ( '@wordpress/a11y' === $id ) { + $a11y_module_available = true; + break; + } + } + } + if ( ! $a11y_module_available ) { + return; + } + echo '
' + . '' + . '
' + . '
' + . '
'; +} +if ( ! method_exists( 'WP_Script_Modules', 'print_a11y_script_module_html' ) ) { + add_action( 'wp_footer', 'gutenberg_a11y_script_module_html' ); + add_action( 'admin_footer', 'gutenberg_a11y_script_module_html' ); +} diff --git a/lib/experimental/media/load.php b/lib/experimental/media/load.php index 5cb16d84e1d8d9..bcb02accf62a6b 100644 --- a/lib/experimental/media/load.php +++ b/lib/experimental/media/load.php @@ -186,6 +186,24 @@ function gutenberg_rest_get_attachment_filesize( array $post ): ?int { return null; } +/** + * Filters the list of rewrite rules formatted for output to an .htaccess file. + * + * Adds support for serving wasm-vips locally. + * + * @param string $rules mod_rewrite Rewrite rules formatted for .htaccess. + * @return string Filtered rewrite rules. + */ +function gutenberg_filter_mod_rewrite_rules( string $rules ): string { + $rules .= "\n# BEGIN Gutenberg client-side media processing experiment\n" . + "AddType application/wasm wasm\n" . + "# END Gutenberg client-side media processing experiment\n"; + + return $rules; +} + +add_filter( 'mod_rewrite_rules', 'gutenberg_filter_mod_rewrite_rules' ); + /** * Enables cross-origin isolation in the block editor. * @@ -236,16 +254,11 @@ function gutenberg_start_cross_origin_isolation_output_buffer(): void { $coep = $is_safari ? 'require-corp' : 'credentialless'; ob_start( - function ( string $output, ?int $phase ) use ( $coep ): string { - // Only send the header when the buffer is not being cleaned. - if ( ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) === 0 ) { - header( 'Cross-Origin-Opener-Policy: same-origin' ); - header( "Cross-Origin-Embedder-Policy: $coep" ); - - $output = gutenberg_add_crossorigin_attributes( $output ); - } + function ( string $output ) use ( $coep ): string { + header( 'Cross-Origin-Opener-Policy: same-origin' ); + header( "Cross-Origin-Embedder-Policy: $coep" ); - return $output; + return gutenberg_add_crossorigin_attributes( $output ); } ); } diff --git a/lib/experimental/script-modules.php b/lib/experimental/script-modules.php index f65bc1704dd890..5a14e1418ed6de 100644 --- a/lib/experimental/script-modules.php +++ b/lib/experimental/script-modules.php @@ -200,65 +200,3 @@ function gutenberg_dequeue_module( $module_identifier ) { _deprecated_function( __FUNCTION__, 'Gutenberg 17.6.0', 'wp_dequeue_script_module' ); wp_script_modules()->dequeue( $module_identifier ); } - -/** - * Prints HTML for the a11y Script Module. - * - * a11y relies on some DOM elements to use as ARIA live regions. - * Ideally, these elements are part of the initial HTML of the page - * so that accessibility tools can find them and observe updates. - */ -function gutenberg_a11y_script_module_html() { - $a11y_module_available = false; - - $get_marked_for_enqueue = new ReflectionMethod( 'WP_Script_Modules', 'get_marked_for_enqueue' ); - $get_marked_for_enqueue->setAccessible( true ); - $get_import_map = new ReflectionMethod( 'WP_Script_Modules', 'get_import_map' ); - $get_import_map->setAccessible( true ); - - foreach ( array_keys( $get_marked_for_enqueue->invoke( wp_script_modules() ) ) as $id ) { - if ( '@wordpress/a11y' === $id ) { - $a11y_module_available = true; - break; - } - } - if ( ! $a11y_module_available ) { - foreach ( array_keys( $get_import_map->invoke( wp_script_modules() )['imports'] ) as $id ) { - if ( '@wordpress/a11y' === $id ) { - $a11y_module_available = true; - break; - } - } - } - if ( ! $a11y_module_available ) { - return; - } - echo '
' - . '' - . '
' - . '
' - . '
'; -} - -/** - * Registers Gutenberg Script Modules. - * - * @since 19.3 - */ -function gutenberg_register_script_modules() { - // When in production, use the plugin's version as the default asset version; - // else (for development or test) default to use the current time. - $default_version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time(); - - wp_deregister_script_module( '@wordpress/a11y' ); - wp_register_script_module( - '@wordpress/a11y', - gutenberg_url( 'build-module/a11y/index.min.js' ), - array(), - $default_version - ); - - add_action( 'wp_footer', 'gutenberg_a11y_script_module_html' ); - add_action( 'admin_footer', 'gutenberg_a11y_script_module_html' ); -} -add_action( 'init', 'gutenberg_register_script_modules' ); diff --git a/lib/interactivity-api.php b/lib/interactivity-api.php index c00d68bc70e8e2..ff68936f054a7e 100644 --- a/lib/interactivity-api.php +++ b/lib/interactivity-api.php @@ -5,37 +5,6 @@ * @package gutenberg */ -/** - * Deregisters the Core Interactivity API Modules and replace them - * with the ones from the Gutenberg plugin. - */ -function gutenberg_reregister_interactivity_script_modules() { - $default_version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time(); - wp_deregister_script_module( '@wordpress/interactivity' ); - wp_deregister_script_module( '@wordpress/interactivity-router' ); - - wp_register_script_module( - '@wordpress/interactivity', - gutenberg_url( '/build-module/' . ( SCRIPT_DEBUG ? 'interactivity/debug.min.js' : 'interactivity/index.min.js' ) ), - array(), - $default_version - ); - - wp_register_script_module( - '@wordpress/interactivity-router', - gutenberg_url( '/build-module/interactivity-router/index.min.js' ), - array( - array( - 'id' => '@wordpress/a11y', - 'import' => 'dynamic', - ), - '@wordpress/interactivity', - ), - $default_version - ); -} -add_action( 'init', 'gutenberg_reregister_interactivity_script_modules' ); - /** * Adds script data to the interactivity-router script module. * diff --git a/lib/rest-api.php b/lib/rest-api.php index ac020e243ec056..7570bb19737233 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -13,9 +13,8 @@ /** * Overrides the REST controller for the `wp_global_styles` post type. * - * @param array $args Array of arguments for registering a post type. + * @param array $args Array of arguments for registering a post type. * See the register_post_type() function for accepted arguments. - * @param string $post_type Post type key. * * @return array Array of arguments for registering a post type. */ diff --git a/package-lock.json b/package-lock.json index f61d8acd98274c..c800d47891c180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "19.3.0-rc.1", + "version": "19.4.0-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "19.3.0-rc.1", + "version": "19.4.0-rc.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -52730,7 +52730,8 @@ "@wordpress/plugins": "file:../plugins", "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", - "change-case": "^4.1.2" + "change-case": "^4.1.2", + "clsx": "^2.1.1" }, "engines": { "node": ">=18.12.0", @@ -53340,6 +53341,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/router": "file:../router", "@wordpress/url": "file:../url" @@ -54196,6 +54198,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", @@ -67939,7 +67942,8 @@ "@wordpress/plugins": "file:../plugins", "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", - "change-case": "^4.1.2" + "change-case": "^4.1.2", + "clsx": "^2.1.1" } }, "@wordpress/block-editor": { @@ -68362,6 +68366,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/router": "file:../router", "@wordpress/url": "file:../url" @@ -68943,6 +68948,7 @@ "version": "file:packages/fields", "requires": { "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", diff --git a/package.json b/package.json index a4cc002adbf8e7..b240c89e76d42d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "19.3.0-rc.1", + "version": "19.4.0-rc.1", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", diff --git a/packages/README.md b/packages/README.md index e780d006f70fbd..b8d2c913a91b9d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -58,7 +58,7 @@ When creating a new package, you need to provide at least the following: Initial release. ``` -To ensure your package is recognised, you should also _manually_ add your new package to the root `package.json` file and then run `npm install` to update the dependencies. +To ensure your package is recognized, you should also _manually_ add your new package to the root `package.json` file and then run `npm install` to update the dependencies. ## Managing Dependencies @@ -104,7 +104,7 @@ Next, you need to run `npm install` in the root of the project to ensure that `p This is the most confusing part of working with [monorepo] which causes a lot of hassles for contributors. The most successful strategy so far is to do the following: 1. First, remove the existing dependency as described in the previous section. -2. Next, add the same dependency back as described in the first section of this chapter. This time it wil get the latest version applied unless you enforce a different version explicitly. +2. Next, add the same dependency back as described in the first section of this chapter. This time it will get the latest version applied unless you enforce a different version explicitly. ### Development Dependencies @@ -152,9 +152,9 @@ It's very important to have a good plan for what a new package will include. All ## Maintaining Changelogs -In maintaining dozens of npm packages, it can be tough to keep track of changes. To simplify the release process, each package includes a `CHANGELOG.md` file which details all published releases and the unreleased ("Unreleased") changes, if any exist. +When maintaining dozens of npm packages, it can be tough to keep track of changes. To simplify the release process, each package includes a `CHANGELOG.md` file which details all published releases and the unreleased ("Unreleased") changes, if any exist. -For each pull request, you should always include relevant changes in a "Unreleased" heading at the top of the file. You should add the heading if it doesn't already exist. +For each pull request, you should always include relevant changes under an "Unreleased" heading at the top of the file. You should add the heading if it doesn't already exist. _Example:_ @@ -200,7 +200,7 @@ Gutenberg uses TypeScript by running the TypeScript compiler (`tsc`) on select p These packages benefit from type checking and produced type declarations in the published packages. To opt-in to TypeScript tooling, packages should include a `tsconfig.json` file in the package root and add an entry to the root `tsconfig.json` references. -The changes will indicate that the package has opted-in and will be included in the TypeScript build process. +The changes will indicate that the package has opted in and will be included in the TypeScript build process. A `tsconfig.json` file should look like the following (comments are not necessary): diff --git a/packages/babel-preset-default/CHANGELOG.md b/packages/babel-preset-default/CHANGELOG.md index c8c3fdb66ecb0e..b31be6ffd8d56d 100644 --- a/packages/babel-preset-default/CHANGELOG.md +++ b/packages/babel-preset-default/CHANGELOG.md @@ -1,8 +1,16 @@ -## Internal +## Unreleased -- Added `addPolyfillComments` option. When used, it will automatically add magic comments to mark files that need `wp-polyfill`. +### Bug Fixes + +- Fix a bug in 8.8.1 due to missing files in the published package ([#65481](https://github.com/WordPress/gutenberg/pull/65481)). + +## 8.8.0 (2024-09-19) + +### Internal + +- Added `addPolyfillComments` option. When used, it will automatically add magic comments to mark files that need `wp-polyfill` ([#65292](https://github.com/WordPress/gutenberg/pull/65292)). ## 8.7.0 (2024-09-05) diff --git a/packages/babel-preset-default/package.json b/packages/babel-preset-default/package.json index f0f015cb2203f7..1203586ec20292 100644 --- a/packages/babel-preset-default/package.json +++ b/packages/babel-preset-default/package.json @@ -26,7 +26,9 @@ }, "files": [ "build", - "index.js" + "index.js", + "polyfill-exclusions.js", + "replace-polyfills.js" ], "main": "index.js", "dependencies": { diff --git a/packages/base-styles/_mixins.scss b/packages/base-styles/_mixins.scss index 91017c8bb99320..65f98bf6f15bfc 100644 --- a/packages/base-styles/_mixins.scss +++ b/packages/base-styles/_mixins.scss @@ -1,6 +1,74 @@ @import "./functions"; @import "./long-content-fade"; +/** + * Typography + */ + +@mixin _text-heading() { + font-family: $font-family-headings; + font-weight: $font-weight-medium; +} + +@mixin _text-body() { + font-family: $font-family-body; + font-weight: $font-weight-regular; +} + +@mixin heading-small() { + @include _text-heading(); + font-size: $font-size-x-small; + line-height: $line-height-x-small; +} + +@mixin heading-medium() { + @include _text-heading(); + font-size: $font-size-medium; + line-height: $line-height-small; +} + +@mixin heading-large() { + @include _text-heading(); + font-size: $font-size-large; + line-height: $line-height-small; +} + +@mixin heading-x-large() { + @include _text-heading(); + font-size: $font-size-x-large; + line-height: $line-height-medium; +} + +@mixin heading-2x-large() { + @include _text-heading(); + font-size: $font-size-2x-large; + line-height: $font-line-height-2x-large; +} + +@mixin body-small() { + @include _text-body(); + font-size: $font-size-small; + line-height: $line-height-x-small; +} + +@mixin body-medium() { + @include _text-body(); + font-size: $font-size-medium; + line-height: $line-height-small; +} + +@mixin body-large() { + @include _text-body(); + font-size: $font-size-large; + line-height: $line-height-medium; +} + +@mixin body-x-large() { + @include _text-body(); + font-size: $font-size-x-large; + line-height: $line-height-x-large; +} + /** * Breakpoint mixins */ diff --git a/packages/base-styles/_variables.scss b/packages/base-styles/_variables.scss index 35092033c552bd..ec0bdf91f2489d 100644 --- a/packages/base-styles/_variables.scss +++ b/packages/base-styles/_variables.scss @@ -12,15 +12,37 @@ * Fonts & basic variables. */ -$default-font: -apple-system, BlinkMacSystemFont,"Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell,"Helvetica Neue", sans-serif; -$default-font-size: 13px; -$default-line-height: 1.4; -$editor-html-font: Menlo, Consolas, monaco, monospace; -$editor-font-size: 16px; -$default-block-margin: 28px; // This value provides a consistent, contiguous spacing between blocks. -$text-editor-font-size: 15px; -$editor-line-height: 1.8; -$mobile-text-min-font-size: 16px; // Any font size below 16px will cause Mobile Safari to "zoom in". +$default-font: -apple-system, BlinkMacSystemFont,"Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell,"Helvetica Neue", sans-serif; // Todo: deprecate in favor of $family variables +$default-line-height: 1.4; // Todo: deprecate in favor of $line-height tokens + +/** + * Typography + */ + +// Sizes +$font-size-x-small: 11px; +$font-size-small: 12px; +$font-size-medium: 13px; +$font-size-large: 15px; +$font-size-x-large: 20px; +$font-size-2x-large: 32px; + +// Line heights +$font-line-height-x-small: 16px; +$font-line-height-small: 20px; +$font-line-height-medium: 24px; +$font-line-height-large: 28px; +$font-line-height-x-large: 32px; +$font-line-height-2x-large: 40px; + +// Weights +$font-weight-regular: 400; +$font-weight-medium: 500; + +// Families +$font-family-headings: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +$font-family-body: -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; +$font-family-mono: Menlo, Consolas, monaco, monospace; /** * Grid System. @@ -91,7 +113,12 @@ $spinner-size: 16px; $canvas-padding: $grid-unit-20; /** - * Editor widths. + * Mobile specific styles + */ +$mobile-text-min-font-size: 16px; // Any font size below 16px will cause Mobile Safari to "zoom in". + +/** + * Editor styles. */ $sidebar-width: 280px; @@ -99,6 +126,11 @@ $content-width: 840px; $wide-content-width: 1100px; $widget-area-width: 700px; $secondary-sidebar-width: 350px; +$editor-font-size: 16px; +$default-block-margin: 28px; // This value provides a consistent, contiguous spacing between blocks. +$text-editor-font-size: 15px; +$editor-line-height: 1.8; +$editor-html-font: $font-family-mono; /** * Block & Editor UI. @@ -117,7 +149,7 @@ $block-padding: 14px; // Used to define space between block footprint and surrou $radius-block-ui: $radius-small; $shadow-popover: $elevation-x-small; $shadow-modal: $elevation-large; - +$default-font-size: $font-size-medium; /** * Block paddings. diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index cc99df6dbeaafc..77238c6f386084 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -8,7 +8,6 @@ $z-layers: ( ".block-editor-block-switcher__arrow": 1, ".block-editor-block-list__block {core/image aligned wide or fullwide}": 20, ".block-library-classic__toolbar": 31, // When scrolled to top this toolbar needs to sit over block-editor-block-toolbar - ".block-editor-block-list__block-selection-button": 22, ".components-form-toggle__input": 1, ".editor-text-editor__toolbar": 1, @@ -70,10 +69,6 @@ $z-layers: ( // Below the media library backdrop (.media-modal-backdrop), which has a z-index of 159900. ".block-editor-global-styles-background-panel__popover": 159900 - 10, - // Small screen inner blocks overlay must be displayed above drop zone, - // settings menu, and movers. - ".block-editor-block-list__layout.has-overlay::after": 60, - // The toolbar, when contextual, should be above any adjacent nested block click overlays. ".block-editor-block-contextual-toolbar": 61, diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index beef057bf05f04..974125a5f3f2c3 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -45,7 +45,8 @@ "@wordpress/plugins": "file:../plugins", "@wordpress/private-apis": "file:../private-apis", "@wordpress/url": "file:../url", - "change-case": "^4.1.2" + "change-case": "^4.1.2", + "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/block-directory/src/components/downloadable-block-list-item/index.js b/packages/block-directory/src/components/downloadable-block-list-item/index.js index ac587dc2d6d0cc..7a5f479174ab25 100644 --- a/packages/block-directory/src/components/downloadable-block-list-item/index.js +++ b/packages/block-directory/src/components/downloadable-block-list-item/index.js @@ -1,9 +1,14 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + /** * WordPress dependencies */ import { __, _n, sprintf } from '@wordpress/i18n'; import { - Button, + Tooltip, Spinner, VisuallyHidden, Composite, @@ -89,77 +94,75 @@ function DownloadableBlockListItem( { item, onClick } ) { statusText = __( 'Installingā€¦' ); } + const itemLabel = getDownloadableBlockLabel( item, { + hasNotice, + isInstalled, + isInstalling, + } ); + return ( - { - event.preventDefault(); - onClick(); - } } - label={ getDownloadableBlockLabel( item, { - hasNotice, - isInstalled, - isInstalling, - } ) } - showTooltip - tooltipPosition="top center" - /> - } - disabled={ isInstalling || ! isInstallable } - > -
- - { isInstalling ? ( - - - - ) : ( - + + - - - { createInterpolateElement( - sprintf( - /* translators: %1$s: block title, %2$s: author name. */ - __( '%1$s by %2$s' ), - decodeEntities( title ), - author - ), - { - span: ( - + accessibleWhenDisabled + disabled={ isInstalling || ! isInstallable } + onClick={ ( event ) => { + event.preventDefault(); + onClick(); + } } + aria-label={ itemLabel } + type="button" + role="option" + > +
+ + { isInstalling ? ( + + + + ) : ( + + ) } +
+ + + { createInterpolateElement( + sprintf( + /* translators: %1$s: block title, %2$s: author name. */ + __( '%1$s by %2$s' ), + decodeEntities( title ), + author ), - } + { + span: ( + + ), + } + ) } + + { hasNotice ? ( + + ) : ( + <> + + { !! statusText + ? statusText + : decodeEntities( description ) } + + { isInstallable && + ! ( isInstalled || isInstalling ) && ( + + { __( 'Install block' ) } + + ) } + ) } - { hasNotice ? ( - - ) : ( - <> - - { !! statusText - ? statusText - : decodeEntities( description ) } - - { isInstallable && - ! ( isInstalled || isInstalling ) && ( - - { __( 'Install block' ) } - - ) } - - ) } - -
+ +
); } diff --git a/packages/block-directory/src/components/downloadable-block-list-item/style.scss b/packages/block-directory/src/components/downloadable-block-list-item/style.scss index 8f95297bd9ef0d..6fce5e1b5b32a7 100644 --- a/packages/block-directory/src/components/downloadable-block-list-item/style.scss +++ b/packages/block-directory/src/components/downloadable-block-list-item/style.scss @@ -1,10 +1,21 @@ .block-directory-downloadable-block-list-item { - padding: $grid-unit-15; + & + & { + margin-top: $grid-unit-05; + } + + display: grid; + grid-template-columns: auto 1fr; + width: 100%; height: auto; + padding: $grid-unit-15; + margin: 0; + + appearance: none; + background: none; + border: 0; text-align: left; - display: grid; - grid-template-columns: auto 1fr; + transition: box-shadow 0.1s linear; // The item contains absolutely positioned items. // Set `position: relative` on the parent to prevent overflow issues @@ -12,13 +23,20 @@ // See: https://github.com/WordPress/gutenberg/issues/63384 position: relative; + + &:not([aria-disabled="true"]) { + cursor: pointer; + } + &:hover { @include button-style__focus(); } - &.is-busy { - background: transparent; + &[data-focus-visible] { + @include button-style__focus(); + } + &.is-installing { .block-directory-downloadable-block-list-item__author { border: 0; clip: rect(1px, 1px, 1px, 1px); @@ -33,11 +51,6 @@ word-wrap: normal !important; } } - - &:disabled, - &[aria-disabled] { - opacity: 1; - } } .block-directory-downloadable-block-list-item__icon { @@ -56,6 +69,11 @@ align-items: center; justify-content: center; } + + .is-installing & { + // Adding an extra 6px to avoid the UI from jumping when the rating bar gets hidden + margin-right: $grid-unit-20 + 6px; + } } .block-directory-block-ratings { diff --git a/packages/block-directory/src/components/downloadable-blocks-panel/style.scss b/packages/block-directory/src/components/downloadable-blocks-panel/style.scss index ff3fdb9ea8e319..f4df5ad4abda53 100644 --- a/packages/block-directory/src/components/downloadable-blocks-panel/style.scss +++ b/packages/block-directory/src/components/downloadable-blocks-panel/style.scss @@ -32,6 +32,3 @@ margin-top: 0; } -.block-directory-downloadable-blocks-panel button { - margin-top: $grid-unit-05; -} diff --git a/packages/block-directory/src/plugins/get-install-missing/index.js b/packages/block-directory/src/plugins/get-install-missing/index.js index 8b192cbe8fdc49..43c051cb9aa371 100644 --- a/packages/block-directory/src/plugins/get-install-missing/index.js +++ b/packages/block-directory/src/plugins/get-install-missing/index.js @@ -101,8 +101,7 @@ const ModifiedWarning = ( { originalBlock, ...props } ) => { ); actions.push( @@ -74,13 +74,13 @@ export default function Pagination( { className="block-editor-patterns__grid-pagination-next" > @@ -89,8 +89,9 @@ export default function Pagination( { onClick={ () => changePage( numPages ) } disabled={ currentPage === numPages } aria-label={ __( 'Last page' ) } - size="default" + size="compact" accessibleWhenDisabled + className="block-editor-patterns__grid-pagination-button" > Ā» diff --git a/packages/block-editor/src/components/block-patterns-paging/style.scss b/packages/block-editor/src/components/block-patterns-paging/style.scss index f5f34d821233aa..85d39f9a36577c 100644 --- a/packages/block-editor/src/components/block-patterns-paging/style.scss +++ b/packages/block-editor/src/components/block-patterns-paging/style.scss @@ -4,37 +4,20 @@ border-top: 1px solid $gray-800; padding: $grid-unit-05; justify-content: center; - .components-button.is-tertiary { - width: auto; - height: $button-size-compact; - justify-content: center; - - &:disabled { - color: $gray-600; - background: none; - } - - &:hover:not(:disabled) { - color: $white; - background-color: $gray-700; - } - } } } .show-icon-labels { - .block-editor-patterns__grid-pagination { - .components-button { - width: auto; - // Hide the button icons when labels are set to display... - span { - display: none; - } - // ... and display labels. - // Uses ::before as ::after is already used for active tab styling. - &::before { - content: attr(aria-label); - } + .block-editor-patterns__grid-pagination-button { + width: auto; + // Hide the button icons when labels are set to display... + span { + display: none; + } + // ... and display labels. + // Uses ::before as ::after is already used for active tab styling. + &::before { + content: attr(aria-label); } } } diff --git a/packages/block-editor/src/components/block-quick-navigation/index.js b/packages/block-editor/src/components/block-quick-navigation/index.js index 4f22c2a266722d..fdb3475b3e180f 100644 --- a/packages/block-editor/src/components/block-quick-navigation/index.js +++ b/packages/block-editor/src/components/block-quick-navigation/index.js @@ -59,8 +59,7 @@ function BlockQuickNavigationItem( { clientId, onSelect } ) { return ( - - ) } - -
- ); -} - -export default forwardRef( BlockSelectionButton ); diff --git a/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js b/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js deleted file mode 100644 index ae03bdb4f51647..00000000000000 --- a/packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * External dependencies - */ -import clsx from 'clsx'; - -/** - * WordPress dependencies - */ -import { forwardRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import BlockSelectionButton from './block-selection-button'; -import { PrivateBlockPopover } from '../block-popover'; -import useBlockToolbarPopoverProps from './use-block-toolbar-popover-props'; -import useSelectedBlockToolProps from './use-selected-block-tool-props'; - -function BlockToolbarBreadcrumb( { clientId, __unstableContentRef }, ref ) { - const { - capturingClientId, - isInsertionPointVisible, - lastClientId, - rootClientId, - } = useSelectedBlockToolProps( clientId ); - - const popoverProps = useBlockToolbarPopoverProps( { - contentElement: __unstableContentRef?.current, - clientId, - } ); - - return ( - - - - ); -} - -export default forwardRef( BlockToolbarBreadcrumb ); diff --git a/packages/block-editor/src/components/block-tools/index.js b/packages/block-editor/src/components/block-tools/index.js index 24f60dbbf970aa..bad331561317f8 100644 --- a/packages/block-editor/src/components/block-tools/index.js +++ b/packages/block-editor/src/components/block-tools/index.js @@ -19,7 +19,6 @@ import { default as InsertionPoint, } from './insertion-point'; import BlockToolbarPopover from './block-toolbar-popover'; -import BlockToolbarBreadcrumb from './block-toolbar-breadcrumb'; import ZoomOutPopover from './zoom-out-popover'; import { store as blockEditorStore } from '../../store'; import usePopoverScroll from '../block-popover/use-popover-scroll'; @@ -35,7 +34,8 @@ function selector( select ) { getSettings, __unstableGetEditorMode, isTyping, - } = select( blockEditorStore ); + isDragging, + } = unlock( select( blockEditorStore ) ); const clientId = getSelectedBlockClientId() || getFirstMultiSelectedBlockClientId(); @@ -47,6 +47,7 @@ function selector( select ) { hasFixedToolbar: getSettings().hasFixedToolbar, isTyping: isTyping(), isZoomOutMode: editorMode === 'zoom-out', + isDragging: isDragging(), }; } @@ -64,10 +65,9 @@ export default function BlockTools( { __unstableContentRef, ...props } ) { - const { clientId, hasFixedToolbar, isTyping, isZoomOutMode } = useSelect( - selector, - [] - ); + const { clientId, hasFixedToolbar, isTyping, isZoomOutMode, isDragging } = + useSelect( selector, [] ); + const isMatch = useShortcutEventMatch(); const { getBlocksByClientId, @@ -78,7 +78,6 @@ export default function BlockTools( { const { getGroupingBlockName } = useSelect( blocksStore ); const { showEmptyBlockSideInserter, - showBreadcrumb, showBlockToolbarPopover, showZoomOutToolbar, } = useShowBlockTools(); @@ -223,14 +222,6 @@ export default function BlockTools( { /> ) } - { showBreadcrumb && ( - - ) } - { showZoomOutToolbar && ( - { isZoomOutMode && ( + { isZoomOutMode && ! isDragging && ( diff --git a/packages/block-editor/src/components/block-tools/insertion-point.js b/packages/block-editor/src/components/block-tools/insertion-point.js index 469f7e53908cb4..891a32eaa5dc9c 100644 --- a/packages/block-editor/src/components/block-tools/insertion-point.js +++ b/packages/block-editor/src/components/block-tools/insertion-point.js @@ -37,7 +37,6 @@ function InbetweenInsertionPointPopover( { rootClientId, isInserterShown, isDistractionFree, - isNavigationMode, isZoomOutMode, } = useSelect( ( select ) => { const { @@ -48,7 +47,6 @@ function InbetweenInsertionPointPopover( { getPreviousBlockClientId, getNextBlockClientId, getSettings, - isNavigationMode: _isNavigationMode, __unstableGetEditorMode, } = select( blockEditorStore ); const insertionPoint = getBlockInsertionPoint(); @@ -78,7 +76,6 @@ function InbetweenInsertionPointPopover( { getBlockListSettings( insertionPoint.rootClientId ) ?.orientation || 'vertical', rootClientId: insertionPoint.rootClientId, - isNavigationMode: _isNavigationMode(), isDistractionFree: settings.isDistractionFree, isInserterShown: insertionPoint?.__unstableWithInserter, isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', @@ -144,7 +141,7 @@ function InbetweenInsertionPointPopover( { }, }; - if ( isDistractionFree && ! isNavigationMode ) { + if ( isDistractionFree ) { return null; } diff --git a/packages/block-editor/src/components/block-tools/style.scss b/packages/block-editor/src/components/block-tools/style.scss index 9f1325d7f95a1a..fe9da021b31823 100644 --- a/packages/block-editor/src/components/block-tools/style.scss +++ b/packages/block-editor/src/components/block-tools/style.scss @@ -84,84 +84,6 @@ } } -/** - * Block Label for Navigation/Selection Mode - */ - -.block-editor-block-list__block-selection-button { - display: inline-flex; - padding: 0 $grid-unit-15; - z-index: z-index(".block-editor-block-list__block-selection-button"); - - // Dark block UI appearance. - border-radius: $radius-small; - background-color: $gray-900; - - font-size: $default-font-size; - height: $block-toolbar-height; - - .block-editor-block-list__block-selection-button__content { - margin: auto; - display: inline-flex; - align-items: center; - - > .components-flex__item { - margin-right: $grid-unit-15 * 0.5; - } - } - .components-button.has-icon.block-selection-button_drag-handle { - cursor: grab; - padding: 0; - height: $grid-unit-30; - min-width: $grid-unit-30; - margin-left: -2px; - - // Drag handle is smaller than the others. - svg { - min-width: 18px; - min-height: 18px; - } - } - - .block-editor-block-icon { - font-size: $default-font-size; - color: $white; - height: $block-toolbar-height; - } - - // The button here has a special style to appear as a toolbar. - .components-button { - min-width: $button-size; - color: $white; - height: $block-toolbar-height; - - // When button is focused, it receives a box-shadow instead of the border. - &:focus { - box-shadow: none; - border: none; - } - - &:active { - color: $white; - } - - // Make sure the button has no hover style when it's disabled. - &[aria-disabled="true"]:hover { - color: $white; - } - - display: flex; - } - .block-selection-button_select-button.components-button { - padding: 0; - } - - .block-editor-block-mover { - background: unset; - border: none; - } -} - // Hide the popover block editor list while dragging. // Using a hacky animation to delay hiding the element. // It's needed because if we hide the element immediately upon dragging, @@ -178,14 +100,10 @@ .components-popover.block-editor-block-list__block-popover { // Position the block toolbar. - .block-editor-block-list__block-selection-button, .block-editor-block-contextual-toolbar { pointer-events: all; margin-top: $grid-unit-10; margin-bottom: $grid-unit-10; - } - - .block-editor-block-contextual-toolbar { border: $border-width solid $gray-900; border-radius: $radius-small; overflow: visible; // allow the parent selector to be visible @@ -283,12 +201,9 @@ background: none; border: none; } -} -.block-editor-block-tools__zoom-out-mode-inserter-button { - visibility: hidden; - - &.is-visible { - visibility: visible; + // Make the spacing consistent between controls. + .components-button { + height: $button-size-next-default-40px; } } diff --git a/packages/block-editor/src/components/block-tools/use-show-block-tools.js b/packages/block-editor/src/components/block-tools/use-show-block-tools.js index 07e0ebd16a64b0..02a8f0583bcddf 100644 --- a/packages/block-editor/src/components/block-tools/use-show-block-tools.js +++ b/packages/block-editor/src/components/block-tools/use-show-block-tools.js @@ -22,7 +22,6 @@ export function useShowBlockTools() { getBlock, getBlockMode, getSettings, - hasMultiSelection, __unstableGetEditorMode, isTyping, } = select( blockEditorStore ); @@ -42,29 +41,20 @@ export function useShowBlockTools() { ! isTyping() && editorMode === 'edit' && isEmptyDefaultBlock; - const maybeShowBreadcrumb = - hasSelectedBlock && - ! hasMultiSelection() && - editorMode === 'navigation'; - const isZoomOut = editorMode === 'zoom-out'; const _showZoomOutToolbar = isZoomOut && block?.attributes?.align === 'full' && - ! _showEmptyBlockSideInserter && - ! maybeShowBreadcrumb; + ! _showEmptyBlockSideInserter; const _showBlockToolbarPopover = ! _showZoomOutToolbar && ! getSettings().hasFixedToolbar && ! _showEmptyBlockSideInserter && hasSelectedBlock && - ! isEmptyDefaultBlock && - ! maybeShowBreadcrumb; + ! isEmptyDefaultBlock; return { showEmptyBlockSideInserter: _showEmptyBlockSideInserter, - showBreadcrumb: - ! _showEmptyBlockSideInserter && maybeShowBreadcrumb, showBlockToolbarPopover: _showBlockToolbarPopover, showZoomOutToolbar: _showZoomOutToolbar, }; diff --git a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js index 8ea80a53830135..961552caa66e01 100644 --- a/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js +++ b/packages/block-editor/src/components/block-tools/zoom-out-mode-inserter-button.js @@ -6,17 +6,11 @@ import clsx from 'clsx'; /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; import { Button } from '@wordpress/components'; import { plus } from '@wordpress/icons'; import { _x } from '@wordpress/i18n'; -function ZoomOutModeInserterButton( { isVisible, onClick } ) { - const [ - zoomOutModeInserterButtonHovered, - setZoomOutModeInserterButtonHovered, - ] = useState( false ); - +function ZoomOutModeInserterButton( { onClick } ) { return ( - } - /> + + + { isActive && } + + } + /> + ); } @@ -143,11 +142,7 @@ function renderShadowToggle() { }; return ( - - } - /> - ) ) } + + + + ) + ) } diff --git a/packages/block-editor/src/components/inspector-controls-tabs/style.scss b/packages/block-editor/src/components/inspector-controls-tabs/style.scss index 863ac3d9bed03a..9c9b04f7b84734 100644 --- a/packages/block-editor/src/components/inspector-controls-tabs/style.scss +++ b/packages/block-editor/src/components/inspector-controls-tabs/style.scss @@ -1,7 +1,3 @@ -.show-icon-labels { - .block-editor-block-inspector__tabs [role="tablist"] { - .components-button { - justify-content: center; - } - } +.block-editor-block-inspector__tabs [role="tablist"] { + width: 100%; } diff --git a/packages/block-editor/src/components/inspector-popover-header/index.js b/packages/block-editor/src/components/inspector-popover-header/index.js index d543ab0298cc62..cf6bf0d3d6796e 100644 --- a/packages/block-editor/src/components/inspector-popover-header/index.js +++ b/packages/block-editor/src/components/inspector-popover-header/index.js @@ -31,8 +31,7 @@ export default function InspectorPopoverHeader( { { actions.map( ( { label, icon, onClick } ) => ( + ); } diff --git a/packages/block-editor/src/components/list-view/style.scss b/packages/block-editor/src/components/list-view/style.scss index 05a04abfd110b4..2916622efabee9 100644 --- a/packages/block-editor/src/components/list-view/style.scss +++ b/packages/block-editor/src/components/list-view/style.scss @@ -41,6 +41,15 @@ &:hover { color: var(--wp-admin-theme-color); } + + svg { + fill: currentColor; + // Optimizate for high contrast modes. + // See also https://blogs.windows.com/msedgedev/2020/09/17/styling-for-windows-high-contrast-with-new-standards-for-forced-colors/. + @media (forced-colors: active) { + fill: CanvasText; + } + } } &:not(.is-selected) .block-editor-list-view-block-select-button { @@ -216,20 +225,15 @@ text-align: left; position: relative; white-space: nowrap; - - &.is-dropping-before::before { - content: ""; - position: absolute; - pointer-events: none; - transition: - border-color 0.1s linear, - border-style 0.1s linear, - box-shadow 0.1s linear; - top: -2px; - right: 0; - left: 0; - border-top: 4px solid var(--wp-admin-theme-color); - } + border-radius: 2px; + box-sizing: border-box; + color: inherit; + font-family: inherit; + font-size: 13px; + font-weight: 400; + margin: 0; + text-decoration: none; + transition: box-shadow 0.1s linear; .components-modal__content & { padding-left: 0; diff --git a/packages/block-editor/src/components/media-placeholder/content.scss b/packages/block-editor/src/components/media-placeholder/content.scss index eeb2928df80baf..2f7bb2e673f12e 100644 --- a/packages/block-editor/src/components/media-placeholder/content.scss +++ b/packages/block-editor/src/components/media-placeholder/content.scss @@ -1,27 +1,11 @@ .block-editor-media-placeholder__url-input-form { - display: flex; - - // Selector requires a lot of specificity to override base styles. - input[type="url"].block-editor-media-placeholder__url-input-field { - width: 100%; - min-width: 200px; - - @include break-small() { - width: 300px; - } - - flex-grow: 1; - border: none; - border-radius: 0; - margin: 2px; + min-width: 260px; + @include break-small() { + width: 300px; } } -.block-editor-media-placeholder__url-input-submit-button { - flex-shrink: 1; -} - .block-editor-media-placeholder__cancel-button.is-link { margin: 1em; display: block; diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index 4d41289f324c0f..f16e4317227235 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -11,6 +11,8 @@ import { FormFileUpload, Placeholder, DropZone, + __experimentalInputControl as InputControl, + __experimentalInputControlSuffixWrapper as InputControlSuffixWrapper, withFilters, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -42,21 +44,23 @@ const InsertFromURLPopover = ( { className="block-editor-media-placeholder__url-input-form" onSubmit={ onSubmit } > - - + value={ name } + label={ label } + /> ); } ) } - + ); } diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js index 95a9c2198e4c71..bf1b730cd67d1a 100644 --- a/packages/block-editor/src/hooks/position.js +++ b/packages/block-editor/src/hooks/position.js @@ -310,6 +310,9 @@ export default { }, }; +// Used for generating the instance ID +const POSITION_BLOCK_PROPS_REFERENCE = {}; + function useBlockProps( { name, style } ) { const hasPositionBlockSupport = hasBlockSupport( name, @@ -318,7 +321,7 @@ function useBlockProps( { name, style } ) { const isPositionDisabled = useIsPositionDisabled( { name } ); const allowPositionStyles = hasPositionBlockSupport && ! isPositionDisabled; - const id = useInstanceId( useBlockProps ); + const id = useInstanceId( POSITION_BLOCK_PROPS_REFERENCE ); // Higher specificity to override defaults in editor UI. const positionSelector = `.wp-container-${ id }.wp-container-${ id }`; diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index e1ebf5fda6b8ee..fdc617fda20c05 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -103,11 +103,7 @@ export const withBlockBindingSupport = createHigherOrderComponent( const sources = useSelect( ( select ) => unlock( select( blocksStore ) ).getAllBlockBindingsSources() ); - const { name, clientId } = props; - const hasParentPattern = !! props.context[ 'pattern/overrides' ]; - const hasPatternOverridesDefaultBinding = - props.attributes.metadata?.bindings?.[ DEFAULT_ATTRIBUTE ] - ?.source === 'core/pattern-overrides'; + const { name, clientId, context, setAttributes } = props; const blockBindings = useMemo( () => replacePatternOverrideDefaultBindings( @@ -121,79 +117,87 @@ export const withBlockBindingSupport = createHigherOrderComponent( // used purposely here to ensure `boundAttributes` is updated whenever // there are attribute updates. // `source.getValues` may also call a selector via `registry.select`. - const boundAttributes = useSelect( () => { - if ( ! blockBindings ) { - return; - } - - const attributes = {}; - - const blockBindingsBySource = new Map(); - - for ( const [ attributeName, binding ] of Object.entries( - blockBindings - ) ) { - const { source: sourceName, args: sourceArgs } = binding; - const source = sources[ sourceName ]; - if ( ! source || ! canBindAttribute( name, attributeName ) ) { - continue; + const updatedContext = {}; + const boundAttributes = useSelect( + ( select ) => { + if ( ! blockBindings ) { + return; } - blockBindingsBySource.set( source, { - ...blockBindingsBySource.get( source ), - [ attributeName ]: { - args: sourceArgs, - }, - } ); - } + const attributes = {}; - if ( blockBindingsBySource.size ) { - for ( const [ source, bindings ] of blockBindingsBySource ) { - // Populate context. - const context = {}; + const blockBindingsBySource = new Map(); - if ( source.usesContext?.length ) { - for ( const key of source.usesContext ) { - context[ key ] = blockContext[ key ]; - } + for ( const [ attributeName, binding ] of Object.entries( + blockBindings + ) ) { + const { source: sourceName, args: sourceArgs } = binding; + const source = sources[ sourceName ]; + if ( + ! source || + ! canBindAttribute( name, attributeName ) + ) { + continue; } - // Get values in batch if the source supports it. - let values = {}; - if ( ! source.getValues ) { - Object.keys( bindings ).forEach( ( attr ) => { - // Default to the `key` or the source label when `getValues` doesn't exist - values[ attr ] = - bindings[ attr ].args?.key || source.label; - } ); - } else { - values = source.getValues( { - registry, - context, - clientId, - bindings, - } ); + // Populate context. + for ( const key of source.usesContext || [] ) { + updatedContext[ key ] = blockContext[ key ]; } - for ( const [ attributeName, value ] of Object.entries( - values - ) ) { - if ( - attributeName === 'url' && - ( ! value || ! isURLLike( value ) ) - ) { - // Return null if value is not a valid URL. - attributes[ attributeName ] = null; + + blockBindingsBySource.set( source, { + ...blockBindingsBySource.get( source ), + [ attributeName ]: { + args: sourceArgs, + }, + } ); + } + + if ( blockBindingsBySource.size ) { + for ( const [ + source, + bindings, + ] of blockBindingsBySource ) { + // Get values in batch if the source supports it. + let values = {}; + if ( ! source.getValues ) { + Object.keys( bindings ).forEach( ( attr ) => { + // Default to the the source label when `getValues` doesn't exist. + values[ attr ] = source.label; + } ); } else { - attributes[ attributeName ] = value; + values = source.getValues( { + select, + context: updatedContext, + clientId, + bindings, + } ); + } + for ( const [ attributeName, value ] of Object.entries( + values + ) ) { + if ( + attributeName === 'url' && + ( ! value || ! isURLLike( value ) ) + ) { + // Return null if value is not a valid URL. + attributes[ attributeName ] = null; + } else { + attributes[ attributeName ] = value; + } } } } - } - return attributes; - }, [ blockBindings, name, clientId, blockContext, registry, sources ] ); + return attributes; + }, + [ blockBindings, name, clientId, updatedContext, sources ] + ); - const { setAttributes } = props; + const hasParentPattern = !! updatedContext[ 'pattern/overrides' ]; + const hasPatternOverridesDefaultBinding = + props.attributes.metadata?.bindings?.[ DEFAULT_ATTRIBUTE ] + ?.source === 'core/pattern-overrides'; const _setAttributes = useCallback( ( nextAttributes ) => { @@ -237,18 +241,10 @@ export const withBlockBindingSupport = createHigherOrderComponent( source, bindings, ] of blockBindingsBySource ) { - // Populate context. - const context = {}; - - if ( source.usesContext?.length ) { - for ( const key of source.usesContext ) { - context[ key ] = blockContext[ key ]; - } - } - source.setValues( { - registry, - context, + select: registry.select, + dispatch: registry.dispatch, + context: updatedContext, clientId, bindings, } ); @@ -278,7 +274,7 @@ export const withBlockBindingSupport = createHigherOrderComponent( blockBindings, name, clientId, - blockContext, + updatedContext, setAttributes, sources, hasPatternOverridesDefaultBinding, @@ -292,6 +288,7 @@ export const withBlockBindingSupport = createHigherOrderComponent( { ...props } attributes={ { ...props.attributes, ...boundAttributes } } setAttributes={ _setAttributes } + context={ { ...context, ...updatedContext } } /> ); diff --git a/packages/block-editor/src/hooks/use-zoom-out.js b/packages/block-editor/src/hooks/use-zoom-out.js index d7e21ec1be0578..2a1b43060c00a9 100644 --- a/packages/block-editor/src/hooks/use-zoom-out.js +++ b/packages/block-editor/src/hooks/use-zoom-out.js @@ -8,46 +8,40 @@ import { useEffect, useRef } from '@wordpress/element'; * Internal dependencies */ import { store as blockEditorStore } from '../store'; +import { unlock } from '../lock-unlock'; /** - * A hook used to set the editor mode to zoomed out mode, invoking the hook sets the mode. + * A hook used to set the zoomed out view, invoking the hook sets the mode. * - * @param {boolean} zoomOut If we should enter into zoomOut mode or not + * @param {boolean} zoomOut If we should zoom out or not. */ export function useZoomOut( zoomOut = true ) { - const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); - const { __unstableGetEditorMode } = useSelect( blockEditorStore ); + const { setZoomLevel } = unlock( useDispatch( blockEditorStore ) ); + const { isZoomOut } = unlock( useSelect( blockEditorStore ) ); - const originalEditingModeRef = useRef( null ); - const mode = __unstableGetEditorMode(); + const originalIsZoomOutRef = useRef( null ); useEffect( () => { // Only set this on mount so we know what to return to when we unmount. - if ( ! originalEditingModeRef.current ) { - originalEditingModeRef.current = mode; + if ( ! originalIsZoomOutRef.current ) { + originalIsZoomOutRef.current = isZoomOut(); } - return () => { - // We need to use __unstableGetEditorMode() here and not `mode`, as mode may not update on unmount - if ( - __unstableGetEditorMode() === 'zoom-out' && - __unstableGetEditorMode() !== originalEditingModeRef.current - ) { - __unstableSetEditorMode( originalEditingModeRef.current ); - } - }; - }, [] ); - - // The effect opens the zoom-out view if we want it open and it's not currently in zoom-out mode. - useEffect( () => { - if ( zoomOut && mode !== 'zoom-out' ) { - __unstableSetEditorMode( 'zoom-out' ); + // The effect opens the zoom-out view if we want it open and the canvas is not currently zoomed-out. + if ( zoomOut && isZoomOut() === false ) { + setZoomLevel( 50 ); } else if ( ! zoomOut && - __unstableGetEditorMode() === 'zoom-out' && - originalEditingModeRef.current !== mode + isZoomOut() && + originalIsZoomOutRef.current !== isZoomOut() ) { - __unstableSetEditorMode( originalEditingModeRef.current ); + setZoomLevel( originalIsZoomOutRef.current ? 50 : 100 ); } - }, [ __unstableGetEditorMode, __unstableSetEditorMode, zoomOut ] ); // Mode is deliberately excluded from the dependencies so that the effect does not run when mode changes. + + return () => { + if ( isZoomOut() && isZoomOut() !== originalIsZoomOutRef.current ) { + setZoomLevel( originalIsZoomOutRef.current ? 50 : 100 ); + } + }; + }, [ isZoomOut, setZoomLevel, zoomOut ] ); } diff --git a/packages/block-editor/src/layouts/flex.js b/packages/block-editor/src/layouts/flex.js index dc7e9d1a167a19..81718449695651 100644 --- a/packages/block-editor/src/layouts/flex.js +++ b/packages/block-editor/src/layouts/flex.js @@ -12,7 +12,6 @@ import { arrowDown, } from '@wordpress/icons'; import { - Button, ToggleControl, Flex, FlexItem, @@ -110,7 +109,6 @@ export default { ) } @@ -190,11 +188,7 @@ export default { }, }; -function FlexLayoutVerticalAlignmentControl( { - layout, - onChange, - isToolbar = false, -} ) { +function FlexLayoutVerticalAlignmentControl( { layout, onChange } ) { const { orientation = 'horizontal' } = layout; const defaultVerticalAlignment = @@ -210,54 +204,17 @@ function FlexLayoutVerticalAlignmentControl( { verticalAlignment: value, } ); }; - if ( isToolbar ) { - return ( - - ); - } - - const verticalAlignmentOptions = [ - { - value: 'flex-start', - label: __( 'Align items top' ), - }, - { - value: 'center', - label: __( 'Align items center' ), - }, - { - value: 'flex-end', - label: __( 'Align items bottom' ), - }, - ]; return ( -
- { __( 'Vertical alignment' ) } -
- { verticalAlignmentOptions.map( ( value, icon, label ) => { - return ( -
-
+ ); } diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index 12f477a95a196b..7205bef5798ec1 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -47,7 +47,6 @@ import { PrivatePublishDateTimePicker } from './components/publish-date-time-pic import useSpacingSizes from './components/spacing-sizes-control/hooks/use-spacing-sizes'; import useBlockDisplayTitle from './components/block-title/use-block-display-title'; import TabbedSidebar from './components/tabbed-sidebar'; -import { useBlockBindingsUtils } from './utils/block-bindings'; /** * Private @wordpress/block-editor APIs. @@ -92,6 +91,5 @@ lock( privateApis, { useBlockDisplayTitle, __unstableBlockStyleVariationOverridesWithConfig, setBackgroundStyleDefaults, - useBlockBindingsUtils, sectionRootClientIdKey, } ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index e91f997ca67837..ee11838395ec5c 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1728,23 +1728,24 @@ export const __unstableSetEditorMode = }; /** - * Action that enables or disables the block moving mode. + * Set the block moving client ID. * - * @param {string|null} hasBlockMovingClientId Enable/Disable block moving mode. + * @deprecated + * + * @return {Object} Action object. */ -export const setBlockMovingClientId = - ( hasBlockMovingClientId = null ) => - ( { dispatch } ) => { - dispatch( { type: 'SET_BLOCK_MOVING_MODE', hasBlockMovingClientId } ); - - if ( hasBlockMovingClientId ) { - speak( - __( - 'Use the Tab key and Arrow keys to choose new block location. Use Left and Right Arrow keys to move between nesting levels. Once location is selected press Enter or Space to move the block.' - ) - ); +export function setBlockMovingClientId() { + deprecated( + 'wp.data.dispatch( "core/block-editor" ).setBlockMovingClientId', + { + since: '6.7', + hint: 'Block moving mode feature has been removed', } + ); + return { + type: 'DO_NOTHING', }; +} /** * Action that duplicates a list of blocks. diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js index dc57d61fd6b76c..5571db0ce91066 100644 --- a/packages/block-editor/src/store/private-actions.js +++ b/packages/block-editor/src/store/private-actions.js @@ -359,6 +359,20 @@ export function expandBlock( clientId ) { }; } +/** + * @param {Object} value + * @param {string} value.rootClientId The root client ID to insert at. + * @param {number} value.index The index to insert at. + * + * @return {Object} Action object. + */ +export function setInsertionPoint( value ) { + return { + type: 'SET_INSERTION_POINT', + value, + }; +} + /** * Temporarily modify/unlock the content-only block for editions. * @@ -383,3 +397,26 @@ export const modifyContentLockBlock = focusModeToRevert ); }; + +/** + * Sets the zoom level. + * + * @param {number} zoom the new zoom level + * @return {Object} Action object. + */ +export function setZoomLevel( zoom = 100 ) { + return { + type: 'SET_ZOOM_LEVEL', + zoom, + }; +} + +/** + * Resets the Zoom state. + * @return {Object} Action object. + */ +export function resetZoomLevel() { + return { + type: 'RESET_ZOOM_LEVEL', + }; +} diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 01ad8f69febc9e..c3228980310656 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -15,6 +15,8 @@ import { getBlockName, getTemplateLock, getClientIdsWithDescendants, + isNavigationMode, + getBlockRootClientId, } from './selectors'; import { checkAllowListRecursive, @@ -115,6 +117,7 @@ export const getEnabledClientIdsTree = createSelector( state.settings.templateLock, state.blockListSettings, state.editorMode, + getSectionRootClientId( state ), ] ); @@ -471,26 +474,57 @@ export function getExpandedBlock( state ) { * with the provided client ID. * * @param {Object} state Global application state. - * @param {Object} clientId Client Id of the block. + * @param {string} clientId Client Id of the block. * * @return {?string} Client ID of the ancestor block that is content locking the block. */ -export const getContentLockingParent = createSelector( - ( state, clientId ) => { - let current = clientId; - let result; - while ( ( current = state.blocks.parents.get( current ) ) ) { - if ( - getBlockName( state, current ) === 'core/block' || - getTemplateLock( state, current ) === 'contentOnly' - ) { - result = current; - } +export const getContentLockingParent = ( state, clientId ) => { + let current = clientId; + let result; + while ( ! result && ( current = state.blocks.parents.get( current ) ) ) { + if ( getTemplateLock( state, current ) === 'contentOnly' ) { + result = current; } - return result; - }, - ( state ) => [ state.blocks.parents, state.blockListSettings ] -); + } + return result; +}; + +/** + * Retrieves the client ID of the parent section block. + * + * @param {Object} state Global application state. + * @param {string} clientId Client Id of the block. + * + * @return {?string} Client ID of the ancestor block that is content locking the block. + */ +export const getParentSectionBlock = ( state, clientId ) => { + let current = clientId; + let result; + while ( ! result && ( current = state.blocks.parents.get( current ) ) ) { + if ( isSectionBlock( state, current ) ) { + result = current; + } + } + return result; +}; + +/** + * Retrieves the client ID is a content locking parent + * + * @param {Object} state Global application state. + * @param {string} clientId Client Id of the block. + * + * @return {boolean} Whether the block is a content locking parent. + */ +export function isSectionBlock( state, clientId ) { + const sectionRootClientId = getSectionRootClientId( state ); + const sectionClientIds = getBlockOrder( state, sectionRootClientId ); + return ( + getBlockName( state, clientId ) === 'core/block' || + getTemplateLock( state, clientId ) === 'contentOnly' || + ( isNavigationMode( state ) && sectionClientIds.includes( clientId ) ) + ); +} /** * Retrieves the client ID of the block that is content locked but is @@ -560,3 +594,93 @@ export function isZoomOutMode( state ) { export function getSectionRootClientId( state ) { return state.settings?.[ sectionRootClientIdKey ]; } + +/** + * Returns the zoom out state. + * + * @param {Object} state Global application state. + * @return {boolean} The zoom out state. + */ +export function getZoomLevel( state ) { + return state.zoomLevel; +} + +/** + * Returns whether the editor is considered zoomed out. + * + * @param {Object} state Global application state. + * @return {boolean} Whether the editor is zoomed. + */ +export function isZoomOut( state ) { + return getZoomLevel( state ) < 100; +} + +/** + * Finds the closest block where the block is allowed to be inserted. + * + * @param {Object} state Editor state. + * @param {string[] | string} name Block name or names. + * @param {string} clientId Default insertion point. + * + * @return {string} clientID of the closest container when the block name can be inserted. + */ +export function getClosestAllowedInsertionPoint( state, name, clientId = '' ) { + const blockNames = Array.isArray( name ) ? name : [ name ]; + const areBlockNamesAllowedInClientId = ( id ) => + blockNames.every( ( currentName ) => + canInsertBlockType( state, currentName, id ) + ); + + // If we're trying to insert at the root level and it's not allowed + // Try the section root instead. + if ( ! clientId ) { + if ( areBlockNamesAllowedInClientId( clientId ) ) { + return clientId; + } + + const sectionRootClientId = getSectionRootClientId( state ); + if ( + sectionRootClientId && + areBlockNamesAllowedInClientId( sectionRootClientId ) + ) { + return sectionRootClientId; + } + return null; + } + + // Traverse the block tree up until we find a place where we can insert. + let current = clientId; + while ( current !== null && ! areBlockNamesAllowedInClientId( current ) ) { + const parentClientId = getBlockRootClientId( state, current ); + current = parentClientId; + } + + return current; +} + +export function getClosestAllowedInsertionPointForPattern( + state, + pattern, + clientId +) { + const { allowedBlockTypes } = getSettings( state ); + const isAllowed = checkAllowListRecursive( + getGrammar( pattern ), + allowedBlockTypes + ); + if ( ! isAllowed ) { + return null; + } + const names = getGrammar( pattern ).map( ( { blockName: name } ) => name ); + return getClosestAllowedInsertionPoint( state, names, clientId ); +} + +/** + * Where the point where the next block will be inserted into. + * + * @param {Object} state + * @return {Object} where the insertion point in the block editor is or null if none is set. + */ +export function getInsertionPoint( state ) { + return state.insertionPoint; +} diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index cd4569c45e5801..f6445f8a3681c9 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -1601,7 +1601,7 @@ export function blocksMode( state = {}, action ) { * * @return {Object} Updated state. */ -export function insertionPoint( state = null, action ) { +export function insertionCue( state = null, action ) { switch ( action.type ) { case 'SHOW_INSERTION_POINT': { const { @@ -1795,11 +1795,6 @@ export const blockListSettings = ( state = {}, action ) => { * @return {string} Updated state. */ export function editorMode( state = 'edit', action ) { - // Let inserting block in navigation mode always trigger Edit mode. - if ( action.type === 'INSERT_BLOCKS' && state === 'navigation' ) { - return 'edit'; - } - if ( action.type === 'SET_EDITOR_MODE' ) { return action.mode; } @@ -1807,26 +1802,6 @@ export function editorMode( state = 'edit', action ) { return state; } -/** - * Reducer returning whether the block moving mode is enabled or not. - * - * @param {string|null} state Current state. - * @param {Object} action Dispatched action. - * - * @return {string|null} Updated state. - */ -export function hasBlockMovingClientId( state = null, action ) { - if ( action.type === 'SET_BLOCK_MOVING_MODE' ) { - return action.hasBlockMovingClientId; - } - - if ( action.type === 'SET_EDITOR_MODE' ) { - return null; - } - - return state; -} - /** * Reducer return an updated state representing the most recent block attribute * update. The state is structured as an object where the keys represent the @@ -2085,6 +2060,44 @@ export function hoveredBlockClientId( state = false, action ) { return state; } +/** + * Reducer setting zoom out state. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {number} Updated state. + */ +export function zoomLevel( state = 100, action ) { + switch ( action.type ) { + case 'SET_ZOOM_LEVEL': + return action.zoom; + case 'RESET_ZOOM_LEVEL': + return 100; + } + + return state; +} + +/** + * Reducer setting the insertion point + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function insertionPoint( state = null, action ) { + switch ( action.type ) { + case 'SET_INSERTION_POINT': + return action.value; + case 'SELECT_BLOCK': + return null; + } + + return state; +} + const combinedReducers = combineReducers( { blocks, isDragging, @@ -2098,13 +2111,13 @@ const combinedReducers = combineReducers( { blocksMode, blockListSettings, insertionPoint, + insertionCue, template, settings, preferences, lastBlockAttributesChange, lastFocus, editorMode, - hasBlockMovingClientId, expandedBlock, highlightedBlock, lastBlockInserted, @@ -2118,6 +2131,7 @@ const combinedReducers = combineReducers( { openedBlockSettingsMenu, registeredInserterMediaCategories, hoveredBlockClientId, + zoomLevel, } ); function withAutomaticChangeReset( reducer ) { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 30fdb76bdbe787..6cf6aae296141f 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -21,7 +21,7 @@ import { createSelector, createRegistrySelector } from '@wordpress/data'; * Internal dependencies */ import { - withRootClientIdOptionKey, + isFiltered, checkAllowListRecursive, checkAllowList, getAllPatternsDependants, @@ -38,6 +38,8 @@ import { getTemporarilyEditingAsBlocks, getTemporarilyEditingFocusModeToRevert, getSectionRootClientId, + isSectionBlock, + getParentSectionBlock, } from './private-selectors'; /** @@ -78,7 +80,9 @@ const EMPTY_ARRAY = []; */ const EMPTY_SET = new Set(); -const EMPTY_OBJECT = {}; +const DEFAULT_INSERTER_OPTIONS = { + [ isFiltered ]: true, +}; /** * Returns a block's name given its client ID, or null if no block exists with @@ -1450,8 +1454,7 @@ export function isCaretWithinFormattedText() { } /** - * Returns the insertion point, the index at which the new inserted block would - * be placed. Defaults to the last index. + * Returns the location of the insertion cue. Defaults to the last index. * * @param {Object} state Editor state. * @@ -1462,11 +1465,11 @@ export const getBlockInsertionPoint = createSelector( let rootClientId, index; const { - insertionPoint, + insertionCue, selection: { selectionEnd }, } = state; - if ( insertionPoint !== null ) { - return insertionPoint; + if ( insertionCue !== null ) { + return insertionCue; } const { clientId } = selectionEnd; @@ -1481,7 +1484,7 @@ export const getBlockInsertionPoint = createSelector( return { rootClientId, index }; }, ( state ) => [ - state.insertionPoint, + state.insertionCue, state.selection.selectionEnd.clientId, state.blocks.parents, state.blocks.order, @@ -1489,14 +1492,14 @@ export const getBlockInsertionPoint = createSelector( ); /** - * Returns true if we should show the block insertion point. + * Returns true if the block insertion point is visible. * * @param {Object} state Global application state. * * @return {?boolean} Whether the insertion point is visible or not. */ export function isBlockInsertionPointVisible( state ) { - return state.insertionPoint !== null; + return state.insertionCue !== null; } /** @@ -1537,6 +1540,59 @@ export function getTemplateLock( state, rootClientId ) { return getBlockListSettings( state, rootClientId )?.templateLock ?? false; } +/** + * Determines if the given block type is visible in the inserter. + * Note that this is different than whether a block is allowed to be inserted. + * In some cases, the block is not allowed in a given position but + * it should still be visible in the inserter to be able to add it + * to a different position. + * + * @param {Object} state Editor state. + * @param {string|Object} blockNameOrType The block type object, e.g., the response + * from the block directory; or a string name of + * an installed block type, e.g.' core/paragraph'. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +const isBlockVisibleInTheInserter = ( state, blockNameOrType ) => { + let blockType; + let blockName; + if ( blockNameOrType && 'object' === typeof blockNameOrType ) { + blockType = blockNameOrType; + blockName = blockNameOrType.name; + } else { + blockType = getBlockType( blockNameOrType ); + blockName = blockNameOrType; + } + if ( ! blockType ) { + return false; + } + + const { allowedBlockTypes } = getSettings( state ); + + const isBlockAllowedInEditor = checkAllowList( + allowedBlockTypes, + blockName, + true + ); + if ( ! isBlockAllowedInEditor ) { + return false; + } + + // If parent blocks are not visible, child blocks should be hidden too. + if ( !! blockType.parent?.length ) { + return blockType.parent.some( + ( name ) => + isBlockVisibleInTheInserter( state, name ) || + // Exception for blocks with post-content parent, + // the root level is often consider as "core/post-content". + // This exception should only apply to the post editor ideally though. + name === 'core/post-content' + ); + } + return true; +}; + /** * Determines if the given block type is allowed to be inserted into the block list. * This function is not exported and not memoized because using a memoized selector @@ -1555,6 +1611,10 @@ const canInsertBlockTypeUnmemoized = ( blockName, rootClientId = null ) => { + if ( ! isBlockVisibleInTheInserter( state, blockName ) ) { + return false; + } + let blockType; if ( blockName && 'object' === typeof blockName ) { blockType = blockName; @@ -1562,23 +1622,14 @@ const canInsertBlockTypeUnmemoized = ( } else { blockType = getBlockType( blockName ); } - if ( ! blockType ) { - return false; - } - - const { allowedBlockTypes } = getSettings( state ); - const isBlockAllowedInEditor = checkAllowList( - allowedBlockTypes, - blockName, - true - ); - if ( ! isBlockAllowedInEditor ) { + const isLocked = !! getTemplateLock( state, rootClientId ); + if ( isLocked ) { return false; } - const isLocked = !! getTemplateLock( state, rootClientId ); - if ( isLocked ) { + const _isSectionBlock = !! isSectionBlock( state, rootClientId ); + if ( _isSectionBlock ) { return false; } @@ -1733,6 +1784,11 @@ export function canRemoveBlock( state, clientId ) { return false; } + const isBlockWithinSection = !! getParentSectionBlock( state, clientId ); + if ( isBlockWithinSection ) { + return false; + } + return getBlockEditingMode( state, rootClientId ) !== 'disabled'; } @@ -1959,6 +2015,7 @@ const buildBlockTypeItem = description: blockType.description, category: blockType.category, keywords: blockType.keywords, + parent: blockType.parent, variations: inserterVariations, example: blockType.example, utility: 1, // Deprecated. @@ -1996,7 +2053,7 @@ const buildBlockTypeItem = */ export const getInserterItems = createRegistrySelector( ( select ) => createSelector( - ( state, rootClientId = null, options = EMPTY_OBJECT ) => { + ( state, rootClientId = null, options = DEFAULT_INSERTER_OPTIONS ) => { const buildReusableBlockInserterItem = ( reusableBlock ) => { const icon = ! reusableBlock.wp_pattern_sync_status ? { @@ -2044,56 +2101,7 @@ export const getInserterItems = createRegistrySelector( ( select ) => ) .map( buildBlockTypeInserterItem ); - if ( options[ withRootClientIdOptionKey ] ) { - blockTypeInserterItems = blockTypeInserterItems.reduce( - ( accumulator, item ) => { - item.rootClientId = rootClientId ?? ''; - - while ( - ! canInsertBlockTypeUnmemoized( - state, - item.name, - item.rootClientId - ) - ) { - if ( ! item.rootClientId ) { - let sectionRootClientId; - try { - sectionRootClientId = - getSectionRootClientId( state ); - } catch ( e ) {} - if ( - sectionRootClientId && - canInsertBlockTypeUnmemoized( - state, - item.name, - sectionRootClientId - ) - ) { - item.rootClientId = sectionRootClientId; - } else { - delete item.rootClientId; - } - break; - } else { - const parentClientId = getBlockRootClientId( - state, - item.rootClientId - ); - item.rootClientId = parentClientId; - } - } - - // We could also add non insertable items and gray them out. - if ( item.hasOwnProperty( 'rootClientId' ) ) { - accumulator.push( item ); - } - - return accumulator; - }, - [] - ); - } else { + if ( options[ isFiltered ] !== false ) { blockTypeInserterItems = blockTypeInserterItems.filter( ( blockType ) => canIncludeBlockTypeInInserter( @@ -2102,6 +2110,19 @@ export const getInserterItems = createRegistrySelector( ( select ) => rootClientId ) ); + } else { + blockTypeInserterItems = blockTypeInserterItems + .filter( ( blockType ) => + isBlockVisibleInTheInserter( state, blockType ) + ) + .map( ( blockType ) => ( { + ...blockType, + isAllowedInCurrentRoot: canIncludeBlockTypeInInserter( + state, + blockType, + rootClientId + ), + } ) ); } const items = blockTypeInserterItems.reduce( @@ -2373,37 +2394,50 @@ const getAllowedPatternsDependants = ( select ) => ( state, rootClientId ) => [ */ export const __experimentalGetAllowedPatterns = createRegistrySelector( ( select ) => { - return createSelector( ( state, rootClientId = null ) => { - const { getAllPatterns } = unlock( select( STORE_NAME ) ); - const patterns = getAllPatterns(); - const { allowedBlockTypes } = getSettings( state ); - const parsedPatterns = patterns - .filter( ( { inserter = true } ) => !! inserter ) - .map( ( pattern ) => { - return { - ...pattern, - get blocks() { - return getParsedPattern( pattern ).blocks; - }, - }; - } ); - - const availableParsedPatterns = parsedPatterns.filter( - ( pattern ) => - checkAllowListRecursive( - getGrammar( pattern ), - allowedBlockTypes - ) - ); - const patternsAllowed = availableParsedPatterns.filter( - ( pattern ) => - getGrammar( pattern ).every( ( { blockName: name } ) => - canInsertBlockType( state, name, rootClientId ) - ) - ); + return createSelector( + ( + state, + rootClientId = null, + options = DEFAULT_INSERTER_OPTIONS + ) => { + const { getAllPatterns } = unlock( select( STORE_NAME ) ); + const patterns = getAllPatterns(); + const { allowedBlockTypes } = getSettings( state ); + const parsedPatterns = patterns + .filter( ( { inserter = true } ) => !! inserter ) + .map( ( pattern ) => { + return { + ...pattern, + get blocks() { + return getParsedPattern( pattern ).blocks; + }, + }; + } ); + + const availableParsedPatterns = parsedPatterns.filter( + ( pattern ) => + checkAllowListRecursive( + getGrammar( pattern ), + allowedBlockTypes + ) + ); + const patternsAllowed = availableParsedPatterns.filter( + ( pattern ) => + getGrammar( pattern ).every( ( { blockName: name } ) => + options[ isFiltered ] !== false + ? canInsertBlockType( + state, + name, + rootClientId + ) + : isBlockVisibleInTheInserter( state, name ) + ) + ); - return patternsAllowed; - }, getAllowedPatternsDependants( select ) ); + return patternsAllowed; + }, + getAllowedPatternsDependants( select ) + ); } ); @@ -2467,7 +2501,7 @@ export const __experimentalGetPatternsByBlockTypes = createRegistrySelector( * Determines the items that appear in the available pattern transforms list. * * For now we only handle blocks without InnerBlocks and take into account - * the `__experimentalRole` property of blocks' attributes for the transformation. + * the `role` property of blocks' attributes for the transformation. * * We return the first set of possible eligible block patterns, * by checking the `blockTypes` property. We still have to recurse through @@ -2489,7 +2523,7 @@ export const __experimentalGetPatternTransformItems = createRegistrySelector( } /** * For now we only handle blocks without InnerBlocks and take into account - * the `__experimentalRole` property of blocks' attributes for the transformation. + * the `role` property of blocks' attributes for the transformation. * Note that the blocks have been retrieved through `getBlock`, which doesn't * return the inner blocks of an inner block controller, so we still need * to check for this case too. @@ -2674,12 +2708,17 @@ export function __unstableGetEditorMode( state ) { /** * Returns whether block moving mode is enabled. * - * @param {Object} state Editor state. - * - * @return {string} Client Id of moving block. + * @deprecated */ -export function hasBlockMovingClientId( state ) { - return state.hasBlockMovingClientId; +export function hasBlockMovingClientId() { + deprecated( + 'wp.data.select( "core/block-editor" ).hasBlockMovingClientId', + { + since: '6.7', + hint: 'Block moving mode feature has been removed', + } + ); + return false; } /** @@ -2862,11 +2901,9 @@ export function __unstableHasActiveBlockOverlayActive( state, clientId ) { '__experimentalDisableBlockOverlay', false ); - const shouldEnableIfUnselected = - editorMode === 'navigation' || - ( blockSupportDisable - ? false - : areInnerBlocksControlled( state, clientId ) ); + const shouldEnableIfUnselected = blockSupportDisable + ? false + : areInnerBlocksControlled( state, clientId ); return ( shouldEnableIfUnselected && @@ -2886,6 +2923,14 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) { return false; } +function isWithinBlock( state, clientId, parentClientId ) { + let parent = state.blocks.parents.get( clientId ); + while ( !! parent && parent !== parentClientId ) { + parent = state.blocks.parents.get( parent ); + } + return parent === parentClientId; +} + /** * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode */ @@ -2926,6 +2971,7 @@ export const getBlockEditingMode = createRegistrySelector( if ( clientId === null ) { clientId = ''; } + // In zoom-out mode, override the behavior set by // __unstableSetBlockEditingMode to only allow editing the top-level // sections. @@ -2943,28 +2989,76 @@ export const getBlockEditingMode = createRegistrySelector( state, sectionRootClientId ); - if ( ! sectionsClientIds?.includes( clientId ) ) { + + // Sections are always contentOnly. + if ( sectionsClientIds?.includes( clientId ) ) { + return 'contentOnly'; + } + + return 'disabled'; + } + + if ( editorMode === 'navigation' ) { + const sectionRootClientId = getSectionRootClientId( state ); + + // The root section is "default mode" + if ( clientId === sectionRootClientId ) { + return 'default'; + } + + // Sections should always be contentOnly in navigation mode. + const sectionsClientIds = getBlockOrder( + state, + sectionRootClientId + ); + if ( sectionsClientIds.includes( clientId ) ) { + return 'contentOnly'; + } + + // Blocks outside sections should be disabled. + const isWithinSectionRoot = isWithinBlock( + state, + clientId, + sectionRootClientId + ); + if ( ! isWithinSectionRoot ) { return 'disabled'; } + + // The rest of the blocks depend on whether they are content blocks or not. + // This "flattens" the sections tree. + const name = getBlockName( state, clientId ); + const { hasContentRoleAttribute } = unlock( + select( blocksStore ) + ); + const isContent = hasContentRoleAttribute( name ); + + return isContent ? 'contentOnly' : 'disabled'; } + // In normal mode, consider that an explicitely set editing mode takes over. const blockEditingMode = state.blockEditingModes.get( clientId ); if ( blockEditingMode ) { return blockEditingMode; } + + // In normal mode, top level is default mode. if ( ! clientId ) { return 'default'; } + const rootClientId = getBlockRootClientId( state, clientId ); const templateLock = getTemplateLock( state, rootClientId ); + // If the parent of the block is contentOnly locked, check whether it's a content block. if ( templateLock === 'contentOnly' ) { const name = getBlockName( state, clientId ); - const isContent = - select( blocksStore ).__experimentalHasContentRoleAttribute( - name - ); + const { hasContentRoleAttribute } = unlock( + select( blocksStore ) + ); + const isContent = hasContentRoleAttribute( name ); return isContent ? 'contentOnly' : 'disabled'; } + // Otherwise, check if there's an ancestor that is contentOnly const parentMode = getBlockEditingMode( state, rootClientId ); return parentMode === 'contentOnly' ? 'default' : parentMode; } diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js index 7576b95866306a..d54a519c9056b6 100644 --- a/packages/block-editor/src/store/test/private-actions.js +++ b/packages/block-editor/src/store/test/private-actions.js @@ -6,6 +6,7 @@ import { showBlockInterface, expandBlock, __experimentalUpdateSettings, + setInsertionPoint, setOpenedBlockSettingsMenu, startDragging, stopDragging, @@ -123,4 +124,18 @@ describe( 'private actions', () => { } ); } ); } ); + + describe( 'setInsertionPoint', () => { + it( 'should return the SET_INSERTION_POINT action', () => { + expect( + setInsertionPoint( { + rootClientId: '', + index: '123', + } ) + ).toEqual( { + type: 'SET_INSERTION_POINT', + value: { rootClientId: '', index: '123' }, + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 45432b750bb9eb..cbb75daa4baaa0 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -124,10 +124,10 @@ describe( 'private selectors', () => { blockEditingModes: new Map( [] ), }; - const __experimentalHasContentRoleAttribute = jest.fn( () => false ); + const hasContentRoleAttribute = jest.fn( () => false ); getBlockEditingMode.registry = { select: jest.fn( () => ( { - __experimentalHasContentRoleAttribute, + hasContentRoleAttribute, } ) ), }; @@ -394,6 +394,10 @@ describe( 'private selectors', () => { parents: new Map( [ [ '6cf70164-9097-4460-bcbf-200560546988', '' ], ] ), + order: new Map( [ + [ '6cf70164-9097-4460-bcbf-200560546988', [] ], + [ '', [ '6cf70164-9097-4460-bcbf-200560546988' ] ], + ] ), }, blockEditingModes: new Map(), }; @@ -424,6 +428,21 @@ describe( 'private selectors', () => { 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', ], ] ), + + order: new Map( [ + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + ], + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + [ '4c2b7140-fffd-44b4-b2a7-820c670a6514' ], + ], + [ '', [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ] ], + ] ), }, blockEditingModes: new Map( [ [ '', 'disabled' ], @@ -461,6 +480,21 @@ describe( 'private selectors', () => { 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', ], ] ), + order: new Map( [ + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' ], + ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c' ], + ], + [ + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + [ '4c2b7140-fffd-44b4-b2a7-820c670a6514' ], + ], + [ '', [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' ] ], + ] ), }, blockEditingModes: new Map( [ [ '', 'disabled' ], diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index cd472fa59ac724..1f1b9a9143d981 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -28,6 +28,7 @@ import { isMultiSelecting, preferences, blocksMode, + insertionCue, insertionPoint, template, blockListSettings, @@ -2378,15 +2379,15 @@ describe( 'state', () => { } ); } ); - describe( 'insertionPoint', () => { + describe( 'insertionCue', () => { it( 'should default to null', () => { - const state = insertionPoint( undefined, {} ); + const state = insertionCue( undefined, {} ); expect( state ).toBe( null ); } ); it( 'should set insertion point', () => { - const state = insertionPoint( null, { + const state = insertionCue( null, { type: 'SHOW_INSERTION_POINT', rootClientId: 'clientId1', index: 0, @@ -2403,7 +2404,7 @@ describe( 'state', () => { rootClientId: 'clientId1', index: 0, } ); - const state = insertionPoint( original, { + const state = insertionCue( original, { type: 'HIDE_INSERTION_POINT', } ); @@ -3485,4 +3486,39 @@ describe( 'state', () => { expect( state ).toBe( null ); } ); } ); + + describe( 'insertionPoint', () => { + it( 'should default to null', () => { + const state = insertionPoint( undefined, {} ); + + expect( state ).toBe( null ); + } ); + + it( 'should set insertion point', () => { + const state = insertionPoint( null, { + type: 'SET_INSERTION_POINT', + value: { + rootClientId: 'clientId1', + index: 4, + }, + } ); + + expect( state ).toEqual( { + rootClientId: 'clientId1', + index: 4, + } ); + } ); + + it( 'should clear the insertion point on block selection', () => { + const original = deepFreeze( { + rootClientId: 'clientId1', + index: 4, + } ); + const state = insertionPoint( original, { + type: 'SELECT_BLOCK', + } ); + + expect( state ).toBe( null ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 85006621c4701e..a08c2e0dde1508 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -15,6 +15,8 @@ import { select, dispatch } from '@wordpress/data'; */ import * as selectors from '../selectors'; import { store } from '../'; +import { sectionRootClientIdKey } from '../private-keys'; +import { lock } from '../../lock-unlock'; const { getBlockName, @@ -2423,7 +2425,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: { + insertionCue: { rootClientId: undefined, index: 0, }, @@ -2464,7 +2466,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: null, + insertionCue: null, }; expect( getBlockInsertionPoint( state ) ).toEqual( { @@ -2502,7 +2504,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: null, + insertionCue: null, }; const insertionPoint1 = getBlockInsertionPoint( state ); @@ -2544,7 +2546,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: null, + insertionCue: null, }; expect( getBlockInsertionPoint( state ) ).toEqual( { @@ -2586,7 +2588,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: null, + insertionCue: null, }; expect( getBlockInsertionPoint( state ) ).toEqual( { @@ -2628,7 +2630,7 @@ describe( 'selectors', () => { } ) ), }, - insertionPoint: null, + insertionCue: null, }; expect( getBlockInsertionPoint( state ) ).toEqual( { @@ -2641,7 +2643,7 @@ describe( 'selectors', () => { describe( 'isBlockInsertionPointVisible', () => { it( 'should return false if no assigned insertion point', () => { const state = { - insertionPoint: null, + insertionCue: null, }; expect( isBlockInsertionPointVisible( state ) ).toBe( false ); @@ -2649,7 +2651,7 @@ describe( 'selectors', () => { it( 'should return true if assigned insertion point', () => { const state = { - insertionPoint: { + insertionCue: { rootClientId: undefined, index: 5, }, @@ -2694,6 +2696,7 @@ describe( 'selectors', () => { byClientId: new Map(), attributes: new Map(), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: { @@ -2711,6 +2714,7 @@ describe( 'selectors', () => { blocks: { byClientId: new Map(), attributes: new Map(), + order: new Map(), }, blockListSettings: {}, settings: { @@ -2728,6 +2732,7 @@ describe( 'selectors', () => { byClientId: new Map(), attributes: new Map(), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2748,6 +2753,7 @@ describe( 'selectors', () => { byClientId: new Map(), attributes: new Map(), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2772,6 +2778,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2796,6 +2803,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { block1: {}, @@ -2822,6 +2830,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { block1: {}, @@ -2848,6 +2857,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { block1: { @@ -2876,6 +2886,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { block1: { @@ -2904,6 +2915,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2932,6 +2944,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { block1: { @@ -2960,6 +2973,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2976,6 +2990,7 @@ describe( 'selectors', () => { byClientId: new Map(), attributes: new Map(), parents: new Map(), + order: new Map(), }, blockListSettings: {}, settings: {}, @@ -2992,7 +3007,7 @@ describe( 'selectors', () => { byClientId: new Map( Object.entries( { block1: { name: 'core/test-block-ancestor' }, - block2: { name: 'core/block' }, + block2: { name: 'core/block1' }, } ) ), attributes: new Map( @@ -3006,6 +3021,10 @@ describe( 'selectors', () => { block2: 'block1', } ) ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + ] ), }, blockListSettings: { block1: {}, @@ -3023,6 +3042,37 @@ describe( 'selectors', () => { ).toBe( true ); } ); + it( 'should prevent blocks from being inserted within sections', () => { + const state = { + blocks: { + byClientId: new Map( + Object.entries( { + block1: { name: 'core/block' }, // reusable blocks are always sections. + } ) + ), + attributes: new Map( + Object.entries( { + block1: {}, + } ) + ), + parents: new Map( + Object.entries( { + block1: '', + } ) + ), + order: new Map( [ [ '', [ 'block1' ] ] ] ), + }, + blockListSettings: { + block1: {}, + }, + settings: {}, + blockEditingModes: new Map(), + }; + expect( + canInsertBlockType( state, 'core/test-block-a', 'block1' ) + ).toBe( false ); + } ); + it( 'should allow blocks to be inserted if both parent and ancestor restrictions are met', () => { const state = { blocks: { @@ -3046,6 +3096,11 @@ describe( 'selectors', () => { block3: 'block2', } ) ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + [ 'block2', [ 'block3' ] ], + ] ), }, blockListSettings: { block1: {}, @@ -3086,6 +3141,11 @@ describe( 'selectors', () => { block3: 'block2', } ) ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + [ 'block2', [ 'block3' ] ], + ] ), }, blockListSettings: { block1: {}, @@ -3126,6 +3186,11 @@ describe( 'selectors', () => { block3: 'block2', } ) ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + [ 'block2', [ 'block3' ] ], + ] ), }, blockListSettings: { block1: {}, @@ -3159,11 +3224,14 @@ describe( 'selectors', () => { block2: {}, } ) ), - parents: new Map( - Object.entries( { - block2: 'block1', - } ) - ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + ] ), + parents: new Map( [ + [ 'block2', 'block1' ], + [ 'block1', '' ], + ] ), }, blockListSettings: { block1: {}, @@ -3203,6 +3271,10 @@ describe( 'selectors', () => { block2: 'block1', } ) ), + order: new Map( [ + [ '', [ 'block1' ] ], + [ 'block1', [ 'block2' ] ], + ] ), }, blockListSettings: { block1: {}, @@ -3240,6 +3312,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { 1: { @@ -3273,6 +3346,7 @@ describe( 'selectors', () => { } ) ), parents: new Map(), + order: new Map(), }, blockListSettings: { 1: { @@ -4310,12 +4384,28 @@ describe( 'getBlockEditingMode', () => { settings: {}, blocks: { byClientId: new Map( [ - [ '6cf70164-9097-4460-bcbf-200560546988', {} ], // Header - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', {} ], // Group - [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', {} ], // | Post Title - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', {} ], // | Post Content - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', {} ], // | | Paragraph - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', {} ], // | | Paragraph + [ + '6cf70164-9097-4460-bcbf-200560546988', + { name: 'core/template-part' }, + ], // Header + [ + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', + { name: 'core/group' }, + ], // Group + [ + 'b26fc763-417d-4f01-b81c-2ec61e14a972', + { name: 'core/post-title' }, + ], // | Post Title + [ + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + { name: 'core/group' }, + ], // | Group + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', { name: 'core/p' } ], // | | Paragraph + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', { name: 'core/p' } ], // | | Paragraph + [ + '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', + { name: 'core/group' }, + ], // | | Group ] ), order: new Map( [ [ @@ -4339,10 +4429,12 @@ describe( 'getBlockEditingMode', () => { [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', + '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', ], ], [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ], [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ], + [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', [] ], ] ), parents: new Map( [ [ '6cf70164-9097-4460-bcbf-200560546988', '' ], @@ -4363,6 +4455,10 @@ describe( 'getBlockEditingMode', () => { 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', ], + [ + '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', + ], ] ), }, blockListSettings: { @@ -4372,11 +4468,22 @@ describe( 'getBlockEditingMode', () => { blockEditingModes: new Map( [] ), }; - const __experimentalHasContentRoleAttribute = jest.fn( () => false ); + const navigationModeStateWithRootSection = { + ...baseState, + editorMode: 'navigation', + settings: { + [ sectionRootClientIdKey ]: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', // The group is the "main" container + }, + }; + + const hasContentRoleAttribute = jest.fn( () => false ); + + const fauxPrivateAPIs = {}; + + lock( fauxPrivateAPIs, { hasContentRoleAttribute } ); + getBlockEditingMode.registry = { - select: jest.fn( () => ( { - __experimentalHasContentRoleAttribute, - } ) ), + select: jest.fn( () => fauxPrivateAPIs ), }; it( 'should return default by default', () => { @@ -4480,7 +4587,7 @@ describe( 'getBlockEditingMode', () => { }, }, }; - __experimentalHasContentRoleAttribute.mockReturnValueOnce( false ); + hasContentRoleAttribute.mockReturnValueOnce( false ); expect( getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) ).toBe( 'disabled' ); @@ -4496,9 +4603,69 @@ describe( 'getBlockEditingMode', () => { }, }, }; - __experimentalHasContentRoleAttribute.mockReturnValueOnce( true ); + hasContentRoleAttribute.mockReturnValueOnce( true ); expect( getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) ).toBe( 'contentOnly' ); } ); + + it( 'in navigation mode, the root section container is default', () => { + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' + ) + ).toBe( 'default' ); + } ); + + it( 'in navigation mode, anything outside the section container is disabled', () => { + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + '6cf70164-9097-4460-bcbf-200560546988' + ) + ).toBe( 'disabled' ); + } ); + + it( 'in navigation mode, sections are contentOnly', () => { + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + 'b26fc763-417d-4f01-b81c-2ec61e14a972' + ) + ).toBe( 'contentOnly' ); + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' + ) + ).toBe( 'contentOnly' ); + } ); + + it( 'in navigation mode, blocks with content attributes within sections are contentOnly', () => { + hasContentRoleAttribute.mockReturnValueOnce( true ); + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + ) + ).toBe( 'contentOnly' ); + + hasContentRoleAttribute.mockReturnValueOnce( true ); + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c' + ) + ).toBe( 'contentOnly' ); + } ); + + it( 'in navigation mode, blocks without content attributes within sections are disabled', () => { + expect( + getBlockEditingMode( + navigationModeStateWithRootSection, + '9b9c5c3f-2e46-4f02-9e14-9fed515b958s' + ) + ).toBe( 'disabled' ); + } ); } ); diff --git a/packages/block-editor/src/store/utils.js b/packages/block-editor/src/store/utils.js index b630912a5163d6..9b83a8f74cf9aa 100644 --- a/packages/block-editor/src/store/utils.js +++ b/packages/block-editor/src/store/utils.js @@ -10,9 +10,9 @@ import { parse as grammarParse } from '@wordpress/block-serialization-default-pa import { selectBlockPatternsKey } from './private-keys'; import { unlock } from '../lock-unlock'; import { STORE_NAME } from './constants'; +import { getSectionRootClientId } from './private-selectors'; -export const withRootClientIdOptionKey = Symbol( 'withRootClientId' ); - +export const isFiltered = Symbol( 'isFiltered' ); const parsedPatternCache = new WeakMap(); const grammarMapCache = new WeakMap(); @@ -117,5 +117,7 @@ export function getInsertBlockTypeDependants( state, rootClientId ) { state.settings.allowedBlockTypes, state.settings.templateLock, state.blockEditingModes, + state.editorMode, + getSectionRootClientId( state ), ]; } diff --git a/packages/block-editor/src/utils/block-bindings.js b/packages/block-editor/src/utils/block-bindings.js index b3daf4f4b36b43..2deeb959371742 100644 --- a/packages/block-editor/src/utils/block-bindings.js +++ b/packages/block-editor/src/utils/block-bindings.js @@ -13,6 +13,55 @@ function isObjectEmpty( object ) { return ! object || Object.keys( object ).length === 0; } +/** + * Contains utils to update the block `bindings` metadata. + * + * @typedef {Object} WPBlockBindingsUtils + * + * @property {Function} updateBlockBindings Updates the value of the bindings connected to block attributes. + * @property {Function} removeAllBlockBindings Removes the bindings property of the `metadata` attribute. + */ + +/** + * Retrieves the existing utils needed to update the block `bindings` metadata. + * They can be used to create, modify, or remove connections from the existing block attributes. + * + * It contains the following utils: + * - `updateBlockBindings`: Updates the value of the bindings connected to block attributes. It can be used to remove a specific binding by setting the value to `undefined`. + * - `removeAllBlockBindings`: Removes the bindings property of the `metadata` attribute. + * + * @since 6.7.0 Introduced in WordPress core. + * + * @return {?WPBlockBindingsUtils} Object containing the block bindings utils. + * + * @example + * ```js + * import { useBlockBindingsUtils } from '@wordpress/block-editor' + * const { updateBlockBindings, removeAllBlockBindings } = useBlockBindingsUtils(); + * + * // Update url and alt attributes. + * updateBlockBindings( { + * url: { + * source: 'core/post-meta', + * args: { + * key: 'url_custom_field', + * }, + * }, + * alt: { + * source: 'core/post-meta', + * args: { + * key: 'text_custom_field', + * }, + * }, + * } ); + * + * // Remove binding from url attribute. + * updateBlockBindings( { url: undefined } ); + * + * // Remove bindings from all attributes. + * removeAllBlockBindings(); + * ``` + */ export function useBlockBindingsUtils() { const { clientId } = useBlockEditContext(); const { updateBlockAttributes } = useDispatch( blockEditorStore ); diff --git a/packages/block-editor/src/utils/index.js b/packages/block-editor/src/utils/index.js index 6f53ba585e5ecb..1b5aa769a13b28 100644 --- a/packages/block-editor/src/utils/index.js +++ b/packages/block-editor/src/utils/index.js @@ -1,2 +1,3 @@ export { default as transformStyles } from './transform-styles'; export { default as getPxFromCssUnit } from './get-px-from-css-unit'; +export { useBlockBindingsUtils } from './block-bindings'; diff --git a/packages/block-library/src/audio/block.json b/packages/block-library/src/audio/block.json index bee2ff6d534a70..9b77efee23cce2 100644 --- a/packages/block-library/src/audio/block.json +++ b/packages/block-library/src/audio/block.json @@ -10,24 +10,24 @@ "attributes": { "blob": { "type": "string", - "__experimentalRole": "local" + "role": "local" }, "src": { "type": "string", "source": "attribute", "selector": "audio", "attribute": "src", - "__experimentalRole": "content" + "role": "content" }, "caption": { "type": "rich-text", "source": "rich-text", "selector": "figcaption", - "__experimentalRole": "content" + "role": "content" }, "id": { "type": "number", - "__experimentalRole": "content" + "role": "content" }, "autoplay": { "type": "boolean", diff --git a/packages/block-library/src/avatar/index.js b/packages/block-library/src/avatar/index.js index d318450aec3903..0b3ad9c62c4e30 100644 --- a/packages/block-library/src/avatar/index.js +++ b/packages/block-library/src/avatar/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 5c90361e6bb435..104b07157cba74 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -32,7 +32,7 @@ import { InnerBlocks, } from '@wordpress/block-editor'; import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; -import { store as blocksStore } from '@wordpress/blocks'; +import { getBlockBindingsSource } from '@wordpress/blocks'; /** * Internal dependencies @@ -196,7 +196,6 @@ function ReusableBlockEdit( { ( select ) => { const { getBlocks, getSettings, getBlockEditingMode } = select( blockEditorStore ); - const { getBlockBindingsSource } = unlock( select( blocksStore ) ); // For editing link to the site editor if the theme and user permissions support it. return { innerBlocks: getBlocks( patternClientId ), diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json index d0f90b93467c9d..2c1c05baa20dd3 100644 --- a/packages/block-library/src/button/block.json +++ b/packages/block-library/src/button/block.json @@ -26,34 +26,34 @@ "source": "attribute", "selector": "a", "attribute": "href", - "__experimentalRole": "content" + "role": "content" }, "title": { "type": "string", "source": "attribute", "selector": "a,button", "attribute": "title", - "__experimentalRole": "content" + "role": "content" }, "text": { "type": "rich-text", "source": "rich-text", "selector": "a,button", - "__experimentalRole": "content" + "role": "content" }, "linkTarget": { "type": "string", "source": "attribute", "selector": "a", "attribute": "target", - "__experimentalRole": "content" + "role": "content" }, "rel": { "type": "string", "source": "attribute", "selector": "a", "attribute": "rel", - "__experimentalRole": "content" + "role": "content" }, "placeholder": { "type": "string" diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index d7b8e6486c3c66..3539fd54f4eece 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -9,7 +9,6 @@ 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 { unlock } from '../lock-unlock'; /** * WordPress dependencies @@ -45,7 +44,7 @@ import { createBlock, cloneBlock, getDefaultBlockName, - store as blocksStore, + getBlockBindingsSource, } from '@wordpress/blocks'; import { useMergeRefs, useRefEffect } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -240,9 +239,9 @@ function ButtonEdit( props ) { return {}; } - const blockBindingsSource = unlock( - select( blocksStore ) - ).getBlockBindingsSource( metadata?.bindings?.url?.source ); + const blockBindingsSource = getBlockBindingsSource( + metadata?.bindings?.url?.source + ); return { lockUrlControls: diff --git a/packages/block-library/src/buttons/style.scss b/packages/block-library/src/buttons/style.scss index 8492553bd50b81..e563f3957f3746 100644 --- a/packages/block-library/src/buttons/style.scss +++ b/packages/block-library/src/buttons/style.scss @@ -2,6 +2,8 @@ $blocks-block__margin: 0.5em; .wp-block-buttons { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; &.is-vertical { flex-direction: column; diff --git a/packages/block-library/src/categories/block.json b/packages/block-library/src/categories/block.json index bfd8461f8eda43..3609bdf9ab97c0 100644 --- a/packages/block-library/src/categories/block.json +++ b/packages/block-library/src/categories/block.json @@ -34,7 +34,7 @@ }, "label": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "showLabel": { "type": "boolean", diff --git a/packages/block-library/src/categories/index.php b/packages/block-library/src/categories/index.php index e15f662bdfbb9b..60a29713b4660d 100644 --- a/packages/block-library/src/categories/index.php +++ b/packages/block-library/src/categories/index.php @@ -49,7 +49,7 @@ function render_block_core_categories( $attributes, $content, $block ) { $show_label = empty( $attributes['showLabel'] ) ? ' screen-reader-text' : ''; $default_label = $taxonomy->label; - $label_text = ! empty( $attributes['label'] ) ? $attributes['label'] : $default_label; + $label_text = ! empty( $attributes['label'] ) ? wp_kses_post( $attributes['label'] ) : $default_label; $wrapper_markup = '
%2$s
'; $items_markup = wp_dropdown_categories( $args ); $type = 'dropdown'; diff --git a/packages/block-library/src/comment-author-name/index.js b/packages/block-library/src/comment-author-name/index.js index 4d85bbebe047be..5bcb6896564807 100644 --- a/packages/block-library/src/comment-author-name/index.js +++ b/packages/block-library/src/comment-author-name/index.js @@ -18,6 +18,7 @@ export const settings = { icon, edit, deprecated, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comment-content/index.js b/packages/block-library/src/comment-content/index.js index 130f1d30125559..aefcef75acf8ae 100644 --- a/packages/block-library/src/comment-content/index.js +++ b/packages/block-library/src/comment-content/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comment-date/index.js b/packages/block-library/src/comment-date/index.js index fddae539acfa34..d95c0a958f9ed8 100644 --- a/packages/block-library/src/comment-date/index.js +++ b/packages/block-library/src/comment-date/index.js @@ -18,6 +18,7 @@ export const settings = { icon, edit, deprecated, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comment-edit-link/index.js b/packages/block-library/src/comment-edit-link/index.js index 6639dda86a7a40..ffe8c98a75dfd9 100644 --- a/packages/block-library/src/comment-edit-link/index.js +++ b/packages/block-library/src/comment-edit-link/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comment-reply-link/index.js b/packages/block-library/src/comment-reply-link/index.js index c04f8ce7b1bba5..a8287f6b08ff35 100644 --- a/packages/block-library/src/comment-reply-link/index.js +++ b/packages/block-library/src/comment-reply-link/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { edit, icon, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comments-pagination-next/block.json b/packages/block-library/src/comments-pagination-next/block.json index 22e20bfa8dbf2d..3f7ebe677328d5 100644 --- a/packages/block-library/src/comments-pagination-next/block.json +++ b/packages/block-library/src/comments-pagination-next/block.json @@ -12,6 +12,11 @@ "type": "string" } }, + "example": { + "attributes": { + "label": "Comments Next Page" + } + }, "usesContext": [ "postId", "comments/paginationArrow" ], "supports": { "reusable": false, diff --git a/packages/block-library/src/comments-pagination-numbers/index.js b/packages/block-library/src/comments-pagination-numbers/index.js index 3fd903e2d9ef48..f769f54b4ac034 100644 --- a/packages/block-library/src/comments-pagination-numbers/index.js +++ b/packages/block-library/src/comments-pagination-numbers/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/comments-pagination-previous/block.json b/packages/block-library/src/comments-pagination-previous/block.json index 0871b000c569dd..eb5203af33c866 100644 --- a/packages/block-library/src/comments-pagination-previous/block.json +++ b/packages/block-library/src/comments-pagination-previous/block.json @@ -12,6 +12,11 @@ "type": "string" } }, + "example": { + "attributes": { + "label": "Comments Previous Page" + } + }, "usesContext": [ "postId", "comments/paginationArrow" ], "supports": { "reusable": false, diff --git a/packages/block-library/src/comments-title/index.js b/packages/block-library/src/comments-title/index.js index 86bdab0dbccbff..69b8228eab892b 100644 --- a/packages/block-library/src/comments-title/index.js +++ b/packages/block-library/src/comments-title/index.js @@ -18,6 +18,7 @@ export const settings = { icon, edit, deprecated, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/cover/edit/index.js b/packages/block-library/src/cover/edit/index.js index ec62bd58a2c33a..804027708881b6 100644 --- a/packages/block-library/src/cover/edit/index.js +++ b/packages/block-library/src/cover/edit/index.js @@ -18,6 +18,7 @@ import { useInnerBlocksProps, __experimentalUseGradient, store as blockEditorStore, + useBlockEditingMode, } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; @@ -278,6 +279,9 @@ function CoverEdit( { const isImageBackground = IMAGE_BACKGROUND_TYPE === backgroundType; const isVideoBackground = VIDEO_BACKGROUND_TYPE === backgroundType; + const blockEditingMode = useBlockEditingMode(); + const hasNonContentControls = blockEditingMode === 'default'; + const [ resizeListener, { height, width } ] = useResizeObserver(); const resizableBoxDimensions = useMemo( () => { return { @@ -447,7 +451,7 @@ function CoverEdit( { <> { blockControls } { inspectorControls } - { isSelected && ( + { hasNonContentControls && isSelected && ( ) }
- { isSelected && ( + { hasNonContentControls && isSelected && ( ) } diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 0669a082b1086f..a16d5a6c2c69c7 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -55,7 +55,6 @@ @import "./query-pagination-numbers/editor.scss"; @import "./post-featured-image/editor.scss"; @import "./post-comments-form/editor.scss"; -@import "./post-content/editor.scss"; @import "./editor-elements.scss"; :root .editor-styles-wrapper { diff --git a/packages/block-library/src/embed/block.json b/packages/block-library/src/embed/block.json index a42aafbab4b0b9..5bfb63b0fa9e94 100644 --- a/packages/block-library/src/embed/block.json +++ b/packages/block-library/src/embed/block.json @@ -9,21 +9,21 @@ "attributes": { "url": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "caption": { "type": "rich-text", "source": "rich-text", "selector": "figcaption", - "__experimentalRole": "content" + "role": "content" }, "type": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "providerNameSlug": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "allowResponsive": { "type": "boolean", @@ -32,12 +32,12 @@ "responsive": { "type": "boolean", "default": false, - "__experimentalRole": "content" + "role": "content" }, "previewable": { "type": "boolean", "default": true, - "__experimentalRole": "content" + "role": "content" } }, "supports": { diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 0526120c4dfc1e..2c5e888c2aff64 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -13,10 +13,11 @@ }, "blob": { "type": "string", - "__experimentalRole": "local" + "role": "local" }, "href": { - "type": "string" + "type": "string", + "role": "content" }, "fileId": { "type": "string", @@ -27,13 +28,15 @@ "fileName": { "type": "rich-text", "source": "rich-text", - "selector": "a:not([download])" + "selector": "a:not([download])", + "role": "content" }, "textLinkHref": { "type": "string", "source": "attribute", "selector": "a:not([download])", - "attribute": "href" + "attribute": "href", + "role": "content" }, "textLinkTarget": { "type": "string", @@ -48,7 +51,8 @@ "downloadButtonText": { "type": "rich-text", "source": "rich-text", - "selector": "a[download]" + "selector": "a[download]", + "role": "content" }, "displayPreview": { "type": "boolean" diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 85cc840201da59..8ea668d56d8545 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -19,18 +19,7 @@ function render_block_core_file( $attributes, $content ) { // If it's interactive, enqueue the script module and add the directives. if ( ! empty( $attributes['displayPreview'] ) ) { - $suffix = wp_scripts_get_suffix(); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - $module_url = gutenberg_url( '/build-module/block-library/file/view.min.js' ); - } - - wp_register_script_module( - '@wordpress/block-library/file', - isset( $module_url ) ? $module_url : includes_url( "blocks/file/view{$suffix}.js" ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - wp_enqueue_script_module( '@wordpress/block-library/file' ); + wp_enqueue_script_module( '@wordpress/block-library/file/view' ); $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json index 53aa0be6744cb9..386c90ac207ad4 100644 --- a/packages/block-library/src/form-input/block.json +++ b/packages/block-library/src/form-input/block.json @@ -23,7 +23,7 @@ "default": "Label", "selector": ".wp-block-form-input__label-content", "source": "rich-text", - "__experimentalRole": "content" + "role": "content" }, "inlineLabel": { "type": "boolean", @@ -41,7 +41,7 @@ "selector": ".wp-block-form-input__input", "source": "attribute", "attribute": "placeholder", - "__experimentalRole": "content" + "role": "content" }, "value": { "type": "string", diff --git a/packages/block-library/src/form-input/deprecated.js b/packages/block-library/src/form-input/deprecated.js index 451cc704a42d55..d974cca387a188 100644 --- a/packages/block-library/src/form-input/deprecated.js +++ b/packages/block-library/src/form-input/deprecated.js @@ -41,7 +41,7 @@ const v2 = { default: 'Label', selector: '.wp-block-form-input__label-content', source: 'html', - __experimentalRole: 'content', + role: 'content', }, inlineLabel: { type: 'boolean', @@ -59,7 +59,7 @@ const v2 = { selector: '.wp-block-form-input__input', source: 'attribute', attribute: 'placeholder', - __experimentalRole: 'content', + role: 'content', }, value: { type: 'string', @@ -155,7 +155,7 @@ const v1 = { default: 'Label', selector: '.wp-block-form-input__label-content', source: 'html', - __experimentalRole: 'content', + role: 'content', }, inlineLabel: { type: 'boolean', @@ -173,7 +173,7 @@ const v1 = { selector: '.wp-block-form-input__input', source: 'attribute', attribute: 'placeholder', - __experimentalRole: 'content', + role: 'content', }, value: { type: 'string', diff --git a/packages/block-library/src/group/editor.scss b/packages/block-library/src/group/editor.scss index 11beecbab0eb68..739a9cd0cf852e 100644 --- a/packages/block-library/src/group/editor.scss +++ b/packages/block-library/src/group/editor.scss @@ -39,9 +39,9 @@ &::after { content: ""; display: flex; - flex: 1 0 $grid-unit-60; + flex: 1 0 $button-size-next-default-40px; pointer-events: none; - min-height: $grid-unit-60 - $border-width - $border-width; + min-height: $button-size-next-default-40px - $border-width - $border-width; border: $border-width dashed currentColor; } diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json index 6e43a18cfba452..2276bcbbb50172 100644 --- a/packages/block-library/src/heading/block.json +++ b/packages/block-library/src/heading/block.json @@ -15,7 +15,7 @@ "type": "rich-text", "source": "rich-text", "selector": "h1,h2,h3,h4,h5,h6", - "__experimentalRole": "content" + "role": "content" }, "level": { "type": "number", diff --git a/packages/block-library/src/heading/deprecated.js b/packages/block-library/src/heading/deprecated.js index a97415712bf07c..76b175ac44fc40 100644 --- a/packages/block-library/src/heading/deprecated.js +++ b/packages/block-library/src/heading/deprecated.js @@ -259,7 +259,7 @@ const v5 = { source: 'html', selector: 'h1,h2,h3,h4,h5,h6', default: '', - __experimentalRole: 'content', + role: 'content', }, level: { type: 'number', diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 6417879164a22b..f441a6e893290b 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -11,14 +11,14 @@ "attributes": { "blob": { "type": "string", - "__experimentalRole": "local" + "role": "local" }, "url": { "type": "string", "source": "attribute", "selector": "img", "attribute": "src", - "__experimentalRole": "content" + "role": "content" }, "alt": { "type": "string", @@ -26,13 +26,13 @@ "selector": "img", "attribute": "alt", "default": "", - "__experimentalRole": "content" + "role": "content" }, "caption": { "type": "rich-text", "source": "rich-text", "selector": "figcaption", - "__experimentalRole": "content" + "role": "content" }, "lightbox": { "type": "object", @@ -45,14 +45,14 @@ "source": "attribute", "selector": "img", "attribute": "title", - "__experimentalRole": "content" + "role": "content" }, "href": { "type": "string", "source": "attribute", "selector": "figure > a", "attribute": "href", - "__experimentalRole": "content" + "role": "content" }, "rel": { "type": "string", @@ -68,7 +68,7 @@ }, "id": { "type": "number", - "__experimentalRole": "content" + "role": "content" }, "width": { "type": "string" diff --git a/packages/block-library/src/image/deprecated.js b/packages/block-library/src/image/deprecated.js index 135463a377131f..6c1db75c5e2aa5 100644 --- a/packages/block-library/src/image/deprecated.js +++ b/packages/block-library/src/image/deprecated.js @@ -559,7 +559,7 @@ const v6 = { source: 'attribute', selector: 'img', attribute: 'src', - __experimentalRole: 'content', + role: 'content', }, alt: { type: 'string', @@ -567,27 +567,27 @@ const v6 = { selector: 'img', attribute: 'alt', default: '', - __experimentalRole: 'content', + role: 'content', }, caption: { type: 'string', source: 'html', selector: 'figcaption', - __experimentalRole: 'content', + role: 'content', }, title: { type: 'string', source: 'attribute', selector: 'img', attribute: 'title', - __experimentalRole: 'content', + role: 'content', }, href: { type: 'string', source: 'attribute', selector: 'figure > a', attribute: 'href', - __experimentalRole: 'content', + role: 'content', }, rel: { type: 'string', @@ -603,7 +603,7 @@ const v6 = { }, id: { type: 'number', - __experimentalRole: 'content', + role: 'content', }, width: { type: 'number', @@ -762,7 +762,7 @@ const v7 = { source: 'attribute', selector: 'img', attribute: 'src', - __experimentalRole: 'content', + role: 'content', }, alt: { type: 'string', @@ -770,27 +770,27 @@ const v7 = { selector: 'img', attribute: 'alt', default: '', - __experimentalRole: 'content', + role: 'content', }, caption: { type: 'string', source: 'html', selector: 'figcaption', - __experimentalRole: 'content', + role: 'content', }, title: { type: 'string', source: 'attribute', selector: 'img', attribute: 'title', - __experimentalRole: 'content', + role: 'content', }, href: { type: 'string', source: 'attribute', selector: 'figure > a', attribute: 'href', - __experimentalRole: 'content', + role: 'content', }, rel: { type: 'string', @@ -806,7 +806,7 @@ const v7 = { }, id: { type: 'number', - __experimentalRole: 'content', + role: 'content', }, width: { type: 'number', @@ -962,7 +962,7 @@ const v8 = { source: 'attribute', selector: 'img', attribute: 'src', - __experimentalRole: 'content', + role: 'content', }, alt: { type: 'string', @@ -970,27 +970,27 @@ const v8 = { selector: 'img', attribute: 'alt', default: '', - __experimentalRole: 'content', + role: 'content', }, caption: { type: 'string', source: 'html', selector: 'figcaption', - __experimentalRole: 'content', + role: 'content', }, title: { type: 'string', source: 'attribute', selector: 'img', attribute: 'title', - __experimentalRole: 'content', + role: 'content', }, href: { type: 'string', source: 'attribute', selector: 'figure > a', attribute: 'href', - __experimentalRole: 'content', + role: 'content', }, rel: { type: 'string', @@ -1006,7 +1006,7 @@ const v8 = { }, id: { type: 'number', - __experimentalRole: 'content', + role: 'content', }, width: { type: 'string', diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index d44dc73abfd855..360c4b8e6127b8 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -7,7 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { isBlobURL, createBlobURL } from '@wordpress/blob'; -import { store as blocksStore, createBlock } from '@wordpress/blocks'; +import { createBlock, getBlockBindingsSource } from '@wordpress/blocks'; import { Placeholder } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -28,7 +28,6 @@ import { useResizeObserver } from '@wordpress/compose'; /** * Internal dependencies */ -import { unlock } from '../lock-unlock'; import { useUploadMediaFromBlobURL } from '../utils/hooks'; import Image from './image'; import { isValidFileType } from './utils'; @@ -372,9 +371,9 @@ export function ImageEdit( { return {}; } - const blockBindingsSource = unlock( - select( blocksStore ) - ).getBlockBindingsSource( metadata?.bindings?.url?.source ); + const blockBindingsSource = getBlockBindingsSource( + metadata?.bindings?.url?.source + ); return { lockUrlControls: diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 1673d36e463d5a..89bf31f92664b9 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -34,7 +34,7 @@ import { useEffect, useMemo, useState, useRef } from '@wordpress/element'; import { __, _x, sprintf, isRTL } from '@wordpress/i18n'; import { DOWN } from '@wordpress/keycodes'; import { getFilename } from '@wordpress/url'; -import { switchToBlockType, store as blocksStore } from '@wordpress/blocks'; +import { getBlockBindingsSource, switchToBlockType } from '@wordpress/blocks'; import { crop, overlayText, upload } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -476,7 +476,6 @@ export default function Image( { if ( ! isSingleSelected ) { return {}; } - const { getBlockBindingsSource } = unlock( select( blocksStore ) ); const { url: urlBinding, alt: altBinding, diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index abbb03c0952452..5d7815a1f2f3fb 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -70,19 +70,7 @@ function render_block_core_image( $attributes, $content, $block ) { isset( $lightbox_settings['enabled'] ) && true === $lightbox_settings['enabled'] ) { - $suffix = wp_scripts_get_suffix(); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - $module_url = gutenberg_url( '/build-module/block-library/image/view.min.js' ); - } - - wp_register_script_module( - '@wordpress/block-library/image', - isset( $module_url ) ? $module_url : includes_url( "blocks/image/view{$suffix}.js" ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - - wp_enqueue_script_module( '@wordpress/block-library/image' ); + wp_enqueue_script_module( '@wordpress/block-library/image/view' ); /* * This render needs to happen in a filter with priority 15 to ensure that diff --git a/packages/block-library/src/list-item/block.json b/packages/block-library/src/list-item/block.json index a4bf2351d97509..6eb30cfe6d0af0 100644 --- a/packages/block-library/src/list-item/block.json +++ b/packages/block-library/src/list-item/block.json @@ -16,7 +16,7 @@ "type": "rich-text", "source": "rich-text", "selector": "li", - "__experimentalRole": "content" + "role": "content" } }, "supports": { diff --git a/packages/block-library/src/list/block.json b/packages/block-library/src/list/block.json index ea07a0eb542df3..4a86def8d687b4 100644 --- a/packages/block-library/src/list/block.json +++ b/packages/block-library/src/list/block.json @@ -12,7 +12,7 @@ "ordered": { "type": "boolean", "default": false, - "__experimentalRole": "content" + "role": "content" }, "values": { "type": "string", @@ -21,7 +21,7 @@ "multiline": "li", "__unstableMultilineWrapperTags": [ "ol", "ul" ], "default": "", - "__experimentalRole": "content" + "role": "content" }, "type": { "type": "string" diff --git a/packages/block-library/src/list/deprecated.js b/packages/block-library/src/list/deprecated.js index edb04dff27c904..13804b7040ed46 100644 --- a/packages/block-library/src/list/deprecated.js +++ b/packages/block-library/src/list/deprecated.js @@ -14,7 +14,7 @@ const v0 = { ordered: { type: 'boolean', default: false, - __experimentalRole: 'content', + role: 'content', }, values: { type: 'string', @@ -23,7 +23,7 @@ const v0 = { multiline: 'li', __unstableMultilineWrapperTags: [ 'ol', 'ul' ], default: '', - __experimentalRole: 'content', + role: 'content', }, type: { type: 'string', @@ -74,7 +74,7 @@ const v1 = { ordered: { type: 'boolean', default: false, - __experimentalRole: 'content', + role: 'content', }, values: { type: 'string', @@ -83,7 +83,7 @@ const v1 = { multiline: 'li', __unstableMultilineWrapperTags: [ 'ol', 'ul' ], default: '', - __experimentalRole: 'content', + role: 'content', }, type: { type: 'string', @@ -144,7 +144,7 @@ const v2 = { ordered: { type: 'boolean', default: false, - __experimentalRole: 'content', + role: 'content', }, values: { type: 'string', @@ -153,7 +153,7 @@ const v2 = { multiline: 'li', __unstableMultilineWrapperTags: [ 'ol', 'ul' ], default: '', - __experimentalRole: 'content', + role: 'content', }, type: { type: 'string', @@ -225,7 +225,7 @@ const v3 = { ordered: { type: 'boolean', default: false, - __experimentalRole: 'content', + role: 'content', }, values: { type: 'string', @@ -234,7 +234,7 @@ const v3 = { multiline: 'li', __unstableMultilineWrapperTags: [ 'ol', 'ul' ], default: '', - __experimentalRole: 'content', + role: 'content', }, type: { type: 'string', diff --git a/packages/block-library/src/media-text/block.json b/packages/block-library/src/media-text/block.json index 42384c0c4478e0..0c2cfc4a14995a 100644 --- a/packages/block-library/src/media-text/block.json +++ b/packages/block-library/src/media-text/block.json @@ -18,7 +18,7 @@ "selector": "figure img", "attribute": "alt", "default": "", - "__experimentalRole": "content" + "role": "content" }, "mediaPosition": { "type": "string", @@ -26,14 +26,14 @@ }, "mediaId": { "type": "number", - "__experimentalRole": "content" + "role": "content" }, "mediaUrl": { "type": "string", "source": "attribute", "selector": "figure video,figure img", "attribute": "src", - "__experimentalRole": "content" + "role": "content" }, "mediaLink": { "type": "string" @@ -52,7 +52,7 @@ "source": "attribute", "selector": "figure a", "attribute": "href", - "__experimentalRole": "content" + "role": "content" }, "rel": { "type": "string", @@ -68,7 +68,7 @@ }, "mediaType": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "mediaWidth": { "type": "number", diff --git a/packages/block-library/src/media-text/deprecated.js b/packages/block-library/src/media-text/deprecated.js index 54c6f863311ffe..24f239a41ed295 100644 --- a/packages/block-library/src/media-text/deprecated.js +++ b/packages/block-library/src/media-text/deprecated.js @@ -172,29 +172,29 @@ const v6Attributes = { selector: 'figure img', attribute: 'alt', default: '', - __experimentalRole: 'content', + role: 'content', }, mediaId: { type: 'number', - __experimentalRole: 'content', + role: 'content', }, mediaUrl: { type: 'string', source: 'attribute', selector: 'figure video,figure img', attribute: 'src', - __experimentalRole: 'content', + role: 'content', }, href: { type: 'string', source: 'attribute', selector: 'figure a', attribute: 'href', - __experimentalRole: 'content', + role: 'content', }, mediaType: { type: 'string', - __experimentalRole: 'content', + role: 'content', }, }; diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index ec72b03b6906f0..fa9bb5a56f8012 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -622,18 +622,7 @@ private static function get_nav_element_directives( $is_interactive ) { */ private static function handle_view_script_module_loading( $attributes, $block, $inner_blocks ) { if ( static::is_interactive( $attributes, $inner_blocks ) ) { - $suffix = wp_scripts_get_suffix(); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - $module_url = gutenberg_url( '/build-module/block-library/navigation/view.min.js' ); - } - - wp_register_script_module( - '@wordpress/block-library/navigation', - isset( $module_url ) ? $module_url : includes_url( "blocks/navigation/view{$suffix}.js" ), - array( '@wordpress/interactivity' ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - wp_enqueue_script_module( '@wordpress/block-library/navigation' ); + wp_enqueue_script_module( '@wordpress/block-library/navigation/view' ); } } @@ -1510,9 +1499,15 @@ function block_core_navigation_mock_parsed_block( $inner_blocks, $post ) { */ function block_core_navigation_insert_hooked_blocks( $inner_blocks, $post ) { $mock_navigation_block = block_core_navigation_mock_parsed_block( $inner_blocks, $post ); - $hooked_blocks = get_hooked_blocks(); - $before_block_visitor = null; - $after_block_visitor = null; + + if ( function_exists( 'apply_block_hooks_to_content' ) ) { + $mock_navigation_block_markup = serialize_block( $mock_navigation_block ); + return apply_block_hooks_to_content( $mock_navigation_block_markup, $post, 'insert_hooked_blocks' ); + } + + $hooked_blocks = get_hooked_blocks(); + $before_block_visitor = null; + $after_block_visitor = null; if ( ! empty( $hooked_blocks ) || has_filter( 'hooked_block_types' ) ) { $before_block_visitor = make_before_block_visitor( $hooked_blocks, $post, 'insert_hooked_blocks' ); diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index f16a7cf0411443..7e004019cbf282 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -15,7 +15,7 @@ "type": "rich-text", "source": "rich-text", "selector": "p", - "__experimentalRole": "content" + "role": "content" }, "dropCap": { "type": "boolean", diff --git a/packages/block-library/src/post-content/editor.scss b/packages/block-library/src/post-content/editor.scss deleted file mode 100644 index 626774697aec5f..00000000000000 --- a/packages/block-library/src/post-content/editor.scss +++ /dev/null @@ -1,4 +0,0 @@ -// Disable text selection in the post content placeholder. -.wp-block-post-content.wp-block-post-content { - user-select: none; -} diff --git a/packages/block-library/src/post-navigation-link/block.json b/packages/block-library/src/post-navigation-link/block.json index ce733759846fee..5f1b295119822a 100644 --- a/packages/block-library/src/post-navigation-link/block.json +++ b/packages/block-library/src/post-navigation-link/block.json @@ -34,6 +34,12 @@ "default": "" } }, + "example": { + "attributes": { + "label": "Next post", + "arrow": "arrow" + } + }, "usesContext": [ "postType" ], "supports": { "reusable": false, diff --git a/packages/block-library/src/post-navigation-link/variations.js b/packages/block-library/src/post-navigation-link/variations.js index 945d6eb550f276..4f52b21338af1e 100644 --- a/packages/block-library/src/post-navigation-link/variations.js +++ b/packages/block-library/src/post-navigation-link/variations.js @@ -15,6 +15,12 @@ const variations = [ icon: next, attributes: { type: 'next' }, scope: [ 'inserter', 'transform' ], + example: { + attributes: { + label: 'Next post', + arrow: 'arrow', + }, + }, }, { name: 'post-previous', @@ -25,6 +31,12 @@ const variations = [ icon: previous, attributes: { type: 'previous' }, scope: [ 'inserter', 'transform' ], + example: { + attributes: { + label: 'Previous post', + arrow: 'arrow', + }, + }, }, ]; diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 64cdd156a54310..9126355c096a57 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -64,11 +64,6 @@ function render_block_core_post_template( $attributes, $content, $block ) { if ( in_the_loop() ) { $query = clone $wp_query; $query->rewind_posts(); - - // If in a single post of any post type, default to the 'post' post type. - if ( is_singular() ) { - query_posts( array( 'post_type' => 'post' ) ); - } } else { $query = $wp_query; } diff --git a/packages/block-library/src/post-time-to-read/index.js b/packages/block-library/src/post-time-to-read/index.js index 95b379f55f0b3f..039923161ca81d 100644 --- a/packages/block-library/src/post-time-to-read/index.js +++ b/packages/block-library/src/post-time-to-read/index.js @@ -12,6 +12,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index a1726ee8b0d43c..c25b8ce37093a5 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -12,7 +12,7 @@ "source": "rich-text", "selector": "pre", "__unstablePreserveWhiteSpace": true, - "__experimentalRole": "content" + "role": "content" } }, "supports": { diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json index 0935f9759668d5..271bba74d0252a 100644 --- a/packages/block-library/src/pullquote/block.json +++ b/packages/block-library/src/pullquote/block.json @@ -11,13 +11,13 @@ "type": "rich-text", "source": "rich-text", "selector": "p", - "__experimentalRole": "content" + "role": "content" }, "citation": { "type": "rich-text", "source": "rich-text", "selector": "cite", - "__experimentalRole": "content" + "role": "content" }, "textAlign": { "type": "string" diff --git a/packages/block-library/src/pullquote/deprecated.js b/packages/block-library/src/pullquote/deprecated.js index 6e6f49da91c6a3..18e47997550782 100644 --- a/packages/block-library/src/pullquote/deprecated.js +++ b/packages/block-library/src/pullquote/deprecated.js @@ -75,14 +75,14 @@ const v5 = { source: 'html', selector: 'blockquote', multiline: 'p', - __experimentalRole: 'content', + role: 'content', }, citation: { type: 'string', source: 'html', selector: 'cite', default: '', - __experimentalRole: 'content', + role: 'content', }, textAlign: { type: 'string', diff --git a/packages/block-library/src/query-no-results/block.json b/packages/block-library/src/query-no-results/block.json index 8f3ba56adcc36a..2f656594afa306 100644 --- a/packages/block-library/src/query-no-results/block.json +++ b/packages/block-library/src/query-no-results/block.json @@ -8,6 +8,16 @@ "parent": [ "core/query" ], "textdomain": "default", "usesContext": [ "queryId", "query" ], + "example": { + "innerBlocks": [ + { + "name": "core/paragraph", + "attributes": { + "content": "No posts were found." + } + } + ] + }, "supports": { "align": true, "reusable": false, diff --git a/packages/block-library/src/query-pagination-numbers/index.js b/packages/block-library/src/query-pagination-numbers/index.js index 3fd903e2d9ef48..f769f54b4ac034 100644 --- a/packages/block-library/src/query-pagination-numbers/index.js +++ b/packages/block-library/src/query-pagination-numbers/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/query-title/block.json b/packages/block-library/src/query-title/block.json index de3e60214685c2..5d5c9113bda084 100644 --- a/packages/block-library/src/query-title/block.json +++ b/packages/block-library/src/query-title/block.json @@ -29,6 +29,11 @@ "default": true } }, + "example": { + "attributes": { + "type": "search" + } + }, "supports": { "align": [ "wide", "full" ], "html": false, diff --git a/packages/block-library/src/query/block.json b/packages/block-library/src/query/block.json index 22bfa7b713801c..b2225192c6b218 100644 --- a/packages/block-library/src/query/block.json +++ b/packages/block-library/src/query/block.json @@ -5,6 +5,7 @@ "title": "Query Loop", "category": "theme", "description": "An advanced block that allows displaying post types based on different query parameters and visual configurations.", + "keywords": [ "posts", "list", "blog", "blogs", "custom post types" ], "textdomain": "default", "attributes": { "queryId": { diff --git a/packages/block-library/src/query/edit/inspector-controls/format-controls.js b/packages/block-library/src/query/edit/inspector-controls/format-controls.js index d26fd9d81ce6f7..15c95f3bbba2e2 100644 --- a/packages/block-library/src/query/edit/inspector-controls/format-controls.js +++ b/packages/block-library/src/query/edit/inspector-controls/format-controls.js @@ -68,7 +68,7 @@ export default function FormatControls( { onChange, query: { format } } ) { .filter( Boolean ); const suggestions = formats - .filter( ( item ) => ! format.includes( item.value ) ) + .filter( ( item ) => ! normalizedFormats.includes( item.value ) ) .map( ( item ) => item.label ); return ( diff --git a/packages/block-library/src/query/edit/inspector-controls/index.js b/packages/block-library/src/query/edit/inspector-controls/index.js index 4085128e9aef1a..3128c3526926f9 100644 --- a/packages/block-library/src/query/edit/inspector-controls/index.js +++ b/packages/block-library/src/query/edit/inspector-controls/index.js @@ -321,7 +321,7 @@ export default function QueryInspectorControls( props ) { dropdownMenuProps={ dropdownMenuProps } > perPage > 0 } > pages > 0 } onDeselect={ () => setQuery( { pages: 0 } ) } > diff --git a/packages/block-library/src/query/edit/inspector-controls/pages-control.js b/packages/block-library/src/query/edit/inspector-controls/pages-control.js index cde61453ea844d..06c6e32b66ad2a 100644 --- a/packages/block-library/src/query/edit/inspector-controls/pages-control.js +++ b/packages/block-library/src/query/edit/inspector-controls/pages-control.js @@ -8,7 +8,7 @@ export const PagesControl = ( { pages, onChange } ) => { return ( { diff --git a/packages/block-library/src/query/edit/inspector-controls/per-page-control.js b/packages/block-library/src/query/edit/inspector-controls/per-page-control.js index 3e0dfbf50b70bd..933bb0851e6257 100644 --- a/packages/block-library/src/query/edit/inspector-controls/per-page-control.js +++ b/packages/block-library/src/query/edit/inspector-controls/per-page-control.js @@ -12,7 +12,7 @@ const PerPageControl = ( { perPage, offset = 0, onChange } ) => { { diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index d10db26529854e..043f351e11d7f1 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -24,27 +24,7 @@ function render_block_core_query( $attributes, $content, $block ) { // Enqueue the script module and add the necessary directives if the block is // interactive. if ( $is_interactive ) { - $suffix = wp_scripts_get_suffix(); - if ( defined( 'IS_GUTENBERG_PLUGIN' ) && IS_GUTENBERG_PLUGIN ) { - $module_url = gutenberg_url( '/build-module/block-library/query/view.min.js' ); - } - - wp_register_script_module( - '@wordpress/block-library/query', - isset( $module_url ) ? $module_url : includes_url( "blocks/query/view{$suffix}.js" ), - array( - array( - 'id' => '@wordpress/interactivity', - 'import' => 'static', - ), - array( - 'id' => '@wordpress/interactivity-router', - 'import' => 'dynamic', - ), - ), - defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) - ); - wp_enqueue_script_module( '@wordpress/block-library/query' ); + wp_enqueue_script_module( '@wordpress/block-library/query/view' ); $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json index 0f9ec97422f64b..2ae37f9f36f766 100644 --- a/packages/block-library/src/quote/block.json +++ b/packages/block-library/src/quote/block.json @@ -14,13 +14,13 @@ "selector": "blockquote", "multiline": "p", "default": "", - "__experimentalRole": "content" + "role": "content" }, "citation": { "type": "rich-text", "source": "rich-text", "selector": "cite", - "__experimentalRole": "content" + "role": "content" }, "textAlign": { "type": "string" diff --git a/packages/block-library/src/quote/deprecated.js b/packages/block-library/src/quote/deprecated.js index 77098b6e753139..4d3efd28e3a22c 100644 --- a/packages/block-library/src/quote/deprecated.js +++ b/packages/block-library/src/quote/deprecated.js @@ -70,14 +70,14 @@ const v4 = { selector: 'blockquote', multiline: 'p', default: '', - __experimentalRole: 'content', + role: 'content', }, citation: { type: 'string', source: 'html', selector: 'cite', default: '', - __experimentalRole: 'content', + role: 'content', }, align: { type: 'string', @@ -138,14 +138,14 @@ const v3 = { selector: 'blockquote', multiline: 'p', default: '', - __experimentalRole: 'content', + role: 'content', }, citation: { type: 'string', source: 'html', selector: 'cite', default: '', - __experimentalRole: 'content', + role: 'content', }, align: { type: 'string', diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index dac4c6b488a97e..c5af5a29d21beb 100644 --- a/packages/block-library/src/search/block.json +++ b/packages/block-library/src/search/block.json @@ -10,7 +10,7 @@ "attributes": { "label": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "showLabel": { "type": "boolean", @@ -19,7 +19,7 @@ "placeholder": { "type": "string", "default": "", - "__experimentalRole": "content" + "role": "content" }, "width": { "type": "number" @@ -29,7 +29,7 @@ }, "buttonText": { "type": "string", - "__experimentalRole": "content" + "role": "content" }, "buttonPosition": { "type": "string", diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index e2f3bb3999e42c..d4ed5b7e3a4055 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -424,13 +424,12 @@ export default function SearchEdit( { } step={ 1 } onChange={ ( newWidth ) => { - const filteredWidth = - widthUnit === '%' && - parseInt( newWidth, 10 ) > 100 - ? 100 - : newWidth; + const parsedNewWidth = + newWidth === '' + ? undefined + : parseInt( newWidth, 10 ); setAttributes( { - width: parseInt( filteredWidth, 10 ), + width: parsedNewWidth, } ); } } onUnitChange={ ( newUnit ) => { @@ -566,7 +565,11 @@ export default function SearchEdit( { set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); diff --git a/packages/block-library/src/site-logo/edit.js b/packages/block-library/src/site-logo/edit.js index dc95d5906d7345..36c217c1bf0c79 100644 --- a/packages/block-library/src/site-logo/edit.js +++ b/packages/block-library/src/site-logo/edit.js @@ -564,6 +564,7 @@ export default function LogoEdit( { iconId={ siteIconId } canUserEdit={ canUserEdit } /> + { canUserEdit && } ); } diff --git a/packages/block-library/src/social-links/editor.scss b/packages/block-library/src/social-links/editor.scss index f9491cc068f159..11f1ed86d11220 100644 --- a/packages/block-library/src/social-links/editor.scss +++ b/packages/block-library/src/social-links/editor.scss @@ -101,19 +101,10 @@ .wp-block-social-links .block-list-appender { position: static; // display inline. - .block-editor-button-block-appender.components-button.components-button { - padding: $grid-unit-10 - 2px; - } -} - -.wp-block-social-links { - &.has-small-icon-size .block-editor-button-block-appender.components-button.components-button { + .block-editor-button-block-appender { + height: 1.5em; + width: 1.5em; + font-size: inherit; padding: 0; } - &.has-large-icon-size .block-editor-button-block-appender.components-button.components-button { - padding: $grid-unit-20 - 2px; - } - &.has-huge-icon-size .block-editor-button-block-appender.components-button.components-button { - padding: $grid-unit-30 - 1px; - } } diff --git a/packages/block-library/src/table-of-contents/block.json b/packages/block-library/src/table-of-contents/block.json index 451d245d867b07..5eb6e729d3f03e 100644 --- a/packages/block-library/src/table-of-contents/block.json +++ b/packages/block-library/src/table-of-contents/block.json @@ -62,6 +62,57 @@ } } }, - "example": {}, + "example": { + "innerBlocks": [ + { + "name": "core/heading", + "attributes": { + "level": 2, + "content": "Heading" + } + }, + { + "name": "core/heading", + "attributes": { + "level": 3, + "content": "Subheading" + } + }, + { + "name": "core/heading", + "attributes": { + "level": 2, + "content": "Heading" + } + }, + { + "name": "core/heading", + "attributes": { + "level": 3, + "content": "Subheading" + } + } + ], + "attributes": { + "headings": [ + { + "content": "Heading", + "level": 2 + }, + { + "content": "Subheading", + "level": 3 + }, + { + "content": "Heading", + "level": 2 + }, + { + "content": "Subheading", + "level": 3 + } + ] + } + }, "style": "wp-block-table-of-contents" } diff --git a/packages/block-library/src/term-description/index.js b/packages/block-library/src/term-description/index.js index 0ff710a91f5d50..330ca05bd174e1 100644 --- a/packages/block-library/src/term-description/index.js +++ b/packages/block-library/src/term-description/index.js @@ -16,6 +16,7 @@ export { metadata, name }; export const settings = { icon, edit, + example: {}, }; export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json index 387ff3dfe17123..81cccd72965b1a 100644 --- a/packages/block-library/src/verse/block.json +++ b/packages/block-library/src/verse/block.json @@ -13,7 +13,7 @@ "source": "rich-text", "selector": "pre", "__unstablePreserveWhiteSpace": true, - "__experimentalRole": "content" + "role": "content" }, "textAlign": { "type": "string" diff --git a/packages/block-library/src/verse/deprecated.js b/packages/block-library/src/verse/deprecated.js index 7e3c96bc80cd98..bd4edc46738c5c 100644 --- a/packages/block-library/src/verse/deprecated.js +++ b/packages/block-library/src/verse/deprecated.js @@ -46,7 +46,7 @@ const v2 = { selector: 'pre', default: '', __unstablePreserveWhiteSpace: true, - __experimentalRole: 'content', + role: 'content', }, textAlign: { type: 'string', diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index 1d3dc75961e8f1..d2dcd95365c3b5 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -18,7 +18,7 @@ "type": "rich-text", "source": "rich-text", "selector": "figcaption", - "__experimentalRole": "content" + "role": "content" }, "controls": { "type": "boolean", @@ -29,7 +29,7 @@ }, "id": { "type": "number", - "__experimentalRole": "content" + "role": "content" }, "loop": { "type": "boolean", @@ -58,14 +58,14 @@ }, "blob": { "type": "string", - "__experimentalRole": "local" + "role": "local" }, "src": { "type": "string", "source": "attribute", "selector": "video", "attribute": "src", - "__experimentalRole": "content" + "role": "content" }, "playsInline": { "type": "boolean", @@ -74,7 +74,7 @@ "attribute": "playsinline" }, "tracks": { - "__experimentalRole": "content", + "role": "content", "type": "array", "items": { "type": "object" diff --git a/packages/blocks/README.md b/packages/blocks/README.md index d724f986b0ca81..f4805e1c60b381 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -102,6 +102,47 @@ _Returns_ - `Object`: All block attributes. +### getBlockAttributesNamesByRole + +Filter block attributes by `role` and return their names. + +_Parameters_ + +- _name_ `string`: Block attribute's name. +- _role_ `string`: The role of a block attribute. + +_Returns_ + +- `string[]`: The attribute names that have the provided role. + +### getBlockBindingsSource + +Returns a registered block bindings source by its name. + +_Parameters_ + +- _name_ `string`: Block bindings source name. + +_Returns_ + +- `?Object`: Block bindings source. + +_Changelog_ + +`6.7.0` Introduced in WordPress core. + +### getBlockBindingsSources + +Returns all registered block bindings sources. + +_Returns_ + +- `Array`: Block bindings sources. + +_Changelog_ + +`6.7.0` Introduced in WordPress core. + ### getBlockContent Given a block object, returns the Block's Inner HTML markup. @@ -479,6 +520,40 @@ _Returns_ - `Array`: A list of blocks. +### registerBlockBindingsSource + +Registers a new block bindings source with an object defining its behavior. Once registered, the source is available to be connected to the supported block attributes. + +_Usage_ + +```js +import { _x } from '@wordpress/i18n'; +import { registerBlockBindingsSource } from '@wordpress/blocks'; + +registerBlockBindingsSource( { + name: 'plugin/my-custom-source', + label: _x( 'My Custom Source', 'block bindings source' ), + usesContext: [ 'postType' ], + getValues: getSourceValues, + setValues: updateMyCustomValuesInBatch, + canUserEditValue: () => true, +} ); +``` + +_Parameters_ + +- _source_ `Object`: Properties of the source to be registered. +- _source.name_ `string`: The unique and machine-readable name. +- _source.label_ `[string]`: Human-readable label. Optional when it is defined in the server. +- _source.usesContext_ `[Array]`: Optional array of context needed by the source only in the editor. +- _source.getValues_ `[Function]`: Optional function to get the values from the source. +- _source.setValues_ `[Function]`: Optional function to update multiple values connected to the source. +- _source.canUserEditValue_ `[Function]`: Optional function to determine if the user can edit the value. + +_Changelog_ + +`6.7.0` Introduced in WordPress core. + ### registerBlockCollection Registers a new block collection to group blocks in the same namespace in the inserter. @@ -780,6 +855,26 @@ _Returns_ - `Array`: Updated Block list. +### unregisterBlockBindingsSource + +Unregisters a block bindings source by providing its name. + +_Usage_ + +```js +import { unregisterBlockBindingsSource } from '@wordpress/blocks'; + +unregisterBlockBindingsSource( 'plugin/my-custom-source' ); +``` + +_Parameters_ + +- _name_ `string`: The name of the block bindings source to unregister. + +_Changelog_ + +`6.7.0` Introduced in WordPress core. + ### unregisterBlockStyle Unregisters a block style for the given block. diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 803467cb2187e2..0b38b8e29e68a0 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -2,12 +2,7 @@ * Internal dependencies */ import { lock } from '../lock-unlock'; -import { - registerBlockBindingsSource, - unregisterBlockBindingsSource, - getBlockBindingsSource, - getBlockBindingsSources, -} from './registration'; +import { isUnmodifiedBlockContent } from './utils'; // The blocktype is the most important concept within the block API. It defines // all aspects of the block configuration and its interfaces, including `edit` @@ -146,6 +141,10 @@ export { unregisterBlockStyle, registerBlockVariation, unregisterBlockVariation, + registerBlockBindingsSource, + unregisterBlockBindingsSource, + getBlockBindingsSource, + getBlockBindingsSources, } from './registration'; export { isUnmodifiedBlock, @@ -155,6 +154,7 @@ export { getBlockLabel as __experimentalGetBlockLabel, getAccessibleBlockLabel as __experimentalGetAccessibleBlockLabel, __experimentalSanitizeBlockAttributes, + getBlockAttributesNamesByRole, __experimentalGetBlockAttributesNamesByRole, } from './utils'; @@ -177,9 +177,4 @@ export { } from './constants'; export const privateApis = {}; -lock( privateApis, { - registerBlockBindingsSource, - unregisterBlockBindingsSource, - getBlockBindingsSource, - getBlockBindingsSources, -} ); +lock( privateApis, { isUnmodifiedBlockContent } ); diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index b0f5ae350759f0..31be38b861c284 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -767,14 +767,15 @@ export const unregisterBlockVariation = ( blockName, variationName ) => { * behavior. Once registered, the source is available to be connected * to the supported block attributes. * + * @since 6.7.0 Introduced in WordPress core. + * * @param {Object} source Properties of the source to be registered. * @param {string} source.name The unique and machine-readable name. - * @param {string} [source.label] Human-readable label. - * @param {Array} [source.usesContext] Array of context needed by the source only in the editor. - * @param {Function} [source.getValues] Function to get the values from the source. - * @param {Function} [source.setValues] Function to update multiple values connected to the source. - * @param {Function} [source.canUserEditValue] Function to determine if the user can edit the value. - * @param {Function} [source.getFieldsList] Function to get the lists of fields to expose in the connections panel. + * @param {string} [source.label] Human-readable label. Optional when it is defined in the server. + * @param {Array} [source.usesContext] Optional array of context needed by the source only in the editor. + * @param {Function} [source.getValues] Optional function to get the values from the source. + * @param {Function} [source.setValues] Optional function to update multiple values connected to the source. + * @param {Function} [source.canUserEditValue] Optional function to determine if the user can edit the value. * * @example * ```js @@ -784,8 +785,9 @@ export const unregisterBlockVariation = ( blockName, variationName ) => { * registerBlockBindingsSource( { * name: 'plugin/my-custom-source', * label: _x( 'My Custom Source', 'block bindings source' ), - * getValues: () => getSourceValues(), - * setValues: () => updateMyCustomValuesInBatch(), + * usesContext: [ 'postType' ], + * getValues: getSourceValues, + * setValues: updateMyCustomValuesInBatch, * canUserEditValue: () => true, * } ); * ``` @@ -903,7 +905,9 @@ export const registerBlockBindingsSource = ( source ) => { }; /** - * Unregisters a block bindings source + * Unregisters a block bindings source by providing its name. + * + * @since 6.7.0 Introduced in WordPress core. * * @param {string} name The name of the block bindings source to unregister. * @@ -924,7 +928,9 @@ export function unregisterBlockBindingsSource( name ) { } /** - * Returns a registered block bindings source. + * Returns a registered block bindings source by its name. + * + * @since 6.7.0 Introduced in WordPress core. * * @param {string} name Block bindings source name. * @@ -937,6 +943,8 @@ export function getBlockBindingsSource( name ) { /** * Returns all registered block bindings sources. * + * @since 6.7.0 Introduced in WordPress core. + * * @return {Array} Block bindings sources. */ export function getBlockBindingsSources() { diff --git a/packages/blocks/src/api/serializer.js b/packages/blocks/src/api/serializer.js index 2e7246ce9584a9..f1fb28e9d9a361 100644 --- a/packages/blocks/src/api/serializer.js +++ b/packages/blocks/src/api/serializer.js @@ -10,6 +10,7 @@ import { import { hasFilter, applyFilters } from '@wordpress/hooks'; import isShallowEqual from '@wordpress/is-shallow-equal'; import { removep } from '@wordpress/autop'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -238,7 +239,17 @@ export function getCommentAttributes( blockType, attributes ) { } // Ignore all local attributes + if ( attributeSchema.role === 'local' ) { + return accumulator; + } + if ( attributeSchema.__experimentalRole === 'local' ) { + deprecated( '__experimentalRole attribute', { + since: '6.7', + version: '6.8', + alternative: 'role attribute', + hint: `Check the block.json of the ${ blockType?.name } block.`, + } ); return accumulator; } diff --git a/packages/blocks/src/api/test/serializer.js b/packages/blocks/src/api/test/serializer.js index 7fed23041daaa6..3c1cbd6d1e74ff 100644 --- a/packages/blocks/src/api/test/serializer.js +++ b/packages/blocks/src/api/test/serializer.js @@ -155,7 +155,7 @@ describe( 'block serializer', () => { attributes: { blob: { type: 'string', - __experimentalRole: 'local', + role: 'local', }, url: { type: 'string', diff --git a/packages/blocks/src/api/test/utils.js b/packages/blocks/src/api/test/utils.js index 9bfef69c4c1428..ad76e89aafe5f0 100644 --- a/packages/blocks/src/api/test/utils.js +++ b/packages/blocks/src/api/test/utils.js @@ -13,7 +13,7 @@ import { getAccessibleBlockLabel, getBlockLabel, __experimentalSanitizeBlockAttributes, - __experimentalGetBlockAttributesNamesByRole, + getBlockAttributesNamesByRole, } from '../utils'; const noop = () => {}; @@ -309,7 +309,7 @@ describe( 'sanitizeBlockAttributes', () => { } ); } ); -describe( '__experimentalGetBlockAttributesNamesByRole', () => { +describe( 'getBlockAttributesNamesByRole', () => { beforeAll( () => { registerBlockType( 'core/test-block-1', { attributes: { @@ -318,15 +318,15 @@ describe( '__experimentalGetBlockAttributesNamesByRole', () => { }, content: { type: 'boolean', - __experimentalRole: 'content', + role: 'content', }, level: { type: 'number', - __experimentalRole: 'content', + role: 'content', }, color: { type: 'string', - __experimentalRole: 'other', + role: 'other', }, }, save: noop, @@ -357,42 +357,28 @@ describe( '__experimentalGetBlockAttributesNamesByRole', () => { ].forEach( unregisterBlockType ); } ); it( 'should return empty array if block has no attributes', () => { - expect( - __experimentalGetBlockAttributesNamesByRole( 'core/test-block-3' ) - ).toEqual( [] ); + expect( getBlockAttributesNamesByRole( 'core/test-block-3' ) ).toEqual( + [] + ); } ); it( 'should return all attribute names if no role is provided', () => { - expect( - __experimentalGetBlockAttributesNamesByRole( 'core/test-block-1' ) - ).toEqual( + expect( getBlockAttributesNamesByRole( 'core/test-block-1' ) ).toEqual( expect.arrayContaining( [ 'align', 'content', 'level', 'color' ] ) ); } ); it( 'should return proper results with existing attributes and provided role', () => { expect( - __experimentalGetBlockAttributesNamesByRole( - 'core/test-block-1', - 'content' - ) + getBlockAttributesNamesByRole( 'core/test-block-1', 'content' ) ).toEqual( expect.arrayContaining( [ 'content', 'level' ] ) ); expect( - __experimentalGetBlockAttributesNamesByRole( - 'core/test-block-1', - 'other' - ) + getBlockAttributesNamesByRole( 'core/test-block-1', 'other' ) ).toEqual( [ 'color' ] ); expect( - __experimentalGetBlockAttributesNamesByRole( - 'core/test-block-1', - 'not-exists' - ) + getBlockAttributesNamesByRole( 'core/test-block-1', 'not-exists' ) ).toEqual( [] ); // A block with no `role` in any attributes. expect( - __experimentalGetBlockAttributesNamesByRole( - 'core/test-block-2', - 'content' - ) + getBlockAttributesNamesByRole( 'core/test-block-2', 'content' ) ).toEqual( [] ); } ); } ); diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index a68937586f9273..7bace4ff84c29b 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -12,6 +12,7 @@ import { Component, isValidElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; import { RichTextData } from '@wordpress/rich-text'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -29,6 +30,30 @@ extend( [ namesPlugin, a11yPlugin ] ); */ const ICON_COLORS = [ '#191e23', '#f8f9f9' ]; +/** + * Determines whether the block's attribute is equal to the default attribute + * which means the attribute is unmodified. + * @param {Object} attributeDefinition The attribute's definition of the block type. + * @param {*} value The attribute's value. + * @return {boolean} Whether the attribute is unmodified. + */ +function isUnmodifiedAttribute( attributeDefinition, value ) { + // Every attribute that has a default must match the default. + if ( attributeDefinition.hasOwnProperty( 'default' ) ) { + return value === attributeDefinition.default; + } + + // The rich text type is a bit different from the rest because it + // has an implicit default value of an empty RichTextData instance, + // so check the length of the value. + if ( attributeDefinition.type === 'rich-text' ) { + return ! value?.length; + } + + // Every attribute that doesn't have a default should be undefined. + return value === undefined; +} + /** * Determines whether the block's attributes are equal to the default attributes * which means the block is unmodified. @@ -42,20 +67,7 @@ export function isUnmodifiedBlock( block ) { ( [ key, definition ] ) => { const value = block.attributes[ key ]; - // Every attribute that has a default must match the default. - if ( definition.hasOwnProperty( 'default' ) ) { - return value === definition.default; - } - - // The rich text type is a bit different from the rest because it - // has an implicit default value of an empty RichTextData instance, - // so check the length of the value. - if ( definition.type === 'rich-text' ) { - return ! value?.length; - } - - // Every attribute that doesn't have a default should be undefined. - return value === undefined; + return isUnmodifiedAttribute( definition, value ); } ); } @@ -72,6 +84,35 @@ export function isUnmodifiedDefaultBlock( block ) { return block.name === getDefaultBlockName() && isUnmodifiedBlock( block ); } +/** + * Determines whether the block content is unmodified. A block content is + * considered unmodified if all the attributes that have a role of 'content' + * are equal to the default attributes (or undefined). + * If the block does not have any attributes with a role of 'content', it + * will be considered unmodified if all the attributes are equal to the default + * attributes (or undefined). + * + * @param {WPBlock} block Block Object + * @return {boolean} Whether the block content is unmodified. + */ +export function isUnmodifiedBlockContent( block ) { + const contentAttributes = getBlockAttributesNamesByRole( + block.name, + 'content' + ); + + if ( contentAttributes.length === 0 ) { + return isUnmodifiedBlock( block ); + } + + return contentAttributes.every( ( key ) => { + const definition = getBlockType( block.name )?.attributes[ key ]; + const value = block.attributes[ key ]; + + return isUnmodifiedAttribute( definition, value ); + } ); +} + /** * Function that checks if the parameter is a valid icon. * @@ -332,7 +373,7 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) { * * @return {string[]} The attribute names that have the provided role. */ -export function __experimentalGetBlockAttributesNamesByRole( name, role ) { +export function getBlockAttributesNamesByRole( name, role ) { const attributes = getBlockType( name )?.attributes; if ( ! attributes ) { return []; @@ -341,12 +382,34 @@ export function __experimentalGetBlockAttributesNamesByRole( name, role ) { if ( ! role ) { return attributesNames; } - return attributesNames.filter( - ( attributeName ) => - attributes[ attributeName ]?.__experimentalRole === role - ); + + return attributesNames.filter( ( attributeName ) => { + const attribute = attributes[ attributeName ]; + if ( attribute?.role === role ) { + return true; + } + if ( attribute?.__experimentalRole === role ) { + deprecated( '__experimentalRole attribute', { + since: '6.7', + version: '6.8', + alternative: 'role attribute', + hint: `Check the block.json of the ${ name } block.`, + } ); + return true; + } + return false; + } ); } +export const __experimentalGetBlockAttributesNamesByRole = ( ...args ) => { + deprecated( '__experimentalGetBlockAttributesNamesByRole', { + since: '6.7', + version: '6.8', + alternative: 'getBlockAttributesNamesByRole', + } ); + return getBlockAttributesNamesByRole( ...args ); +}; + /** * Return a new object with the specified keys omitted. * diff --git a/packages/blocks/src/store/private-selectors.js b/packages/blocks/src/store/private-selectors.js index 4cded8268ae97c..d5665323859e40 100644 --- a/packages/blocks/src/store/private-selectors.js +++ b/packages/blocks/src/store/private-selectors.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { createSelector } from '@wordpress/data'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -209,3 +210,36 @@ export function getAllBlockBindingsSources( state ) { export function getBlockBindingsSource( state, sourceName ) { return state.blockBindingsSources[ sourceName ]; } + +/** + * Determines if any of the block type's attributes have + * the content role attribute. + * + * @param {Object} state Data state. + * @param {string} blockTypeName Block type name. + * @return {boolean} Whether block type has content role attribute. + */ +export const hasContentRoleAttribute = ( state, blockTypeName ) => { + const blockType = getBlockType( state, blockTypeName ); + if ( ! blockType ) { + return false; + } + + return Object.values( blockType.attributes ).some( + ( { role, __experimentalRole } ) => { + if ( role === 'content' ) { + return true; + } + if ( __experimentalRole === 'content' ) { + deprecated( '__experimentalRole attribute', { + since: '6.7', + version: '6.8', + alternative: 'role attribute', + hint: `Check the block.json of the ${ blockTypeName } block.`, + } ); + return true; + } + return false; + } + ); +}; diff --git a/packages/blocks/src/store/reducer.js b/packages/blocks/src/store/reducer.js index fbcec7a619cf63..7c7fb4763a1cb6 100644 --- a/packages/blocks/src/store/reducer.js +++ b/packages/blocks/src/store/reducer.js @@ -393,6 +393,13 @@ function getMergedUsesContext( existingUsesContext = [], newUsesContext = [] ) { export function blockBindingsSources( state = {}, action ) { switch ( action.type ) { case 'ADD_BLOCK_BINDINGS_SOURCE': + // Only open this API in Gutenberg and for `core/post-meta` for the moment. + let getFieldsList; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + getFieldsList = action.getFieldsList; + } else if ( action.name === 'core/post-meta' ) { + getFieldsList = action.getFieldsList; + } return { ...state, [ action.name ]: { @@ -404,8 +411,10 @@ export function blockBindingsSources( state = {}, action ) { ), getValues: action.getValues, setValues: action.setValues, - canUserEditValue: action.canUserEditValue, - getFieldsList: action.getFieldsList, + // Only set `canUserEditValue` if `setValues` is also defined. + canUserEditValue: + action.setValues && action.canUserEditValue, + getFieldsList, }, }; case 'ADD_BOOTSTRAPPED_BLOCK_BINDINGS_SOURCE': diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js index e97048e92b0c07..79e88073ba20de 100644 --- a/packages/blocks/src/store/selectors.js +++ b/packages/blocks/src/store/selectors.js @@ -8,11 +8,13 @@ import removeAccents from 'remove-accents'; */ import { createSelector } from '@wordpress/data'; import { RichTextData } from '@wordpress/rich-text'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ import { getValueFromObjectPath, matchesAttributes } from './utils'; +import { hasContentRoleAttribute as privateHasContentRoleAttribute } from './private-selectors'; /** @typedef {import('../api/registration').WPBlockVariation} WPBlockVariation */ /** @typedef {import('../api/registration').WPBlockVariationScope} WPBlockVariationScope */ @@ -822,23 +824,11 @@ export const hasChildBlocksWithInserterSupport = ( state, blockName ) => { } ); }; -/** - * DO-NOT-USE in production. - * This selector is created for internal/experimental only usage and may be - * removed anytime without any warning, causing breakage on any plugin or theme invoking it. - */ -export const __experimentalHasContentRoleAttribute = createSelector( - ( state, blockTypeName ) => { - const blockType = getBlockType( state, blockTypeName ); - if ( ! blockType ) { - return false; - } - - return Object.entries( blockType.attributes ).some( - ( [ , { __experimentalRole } ] ) => __experimentalRole === 'content' - ); - }, - ( state, blockTypeName ) => [ - state.blockTypes[ blockTypeName ]?.attributes, - ] -); +export const __experimentalHasContentRoleAttribute = ( ...args ) => { + deprecated( '__experimentalHasContentRoleAttribute', { + since: '6.7', + version: '6.8', + hint: 'This is a private selector.', + } ); + return privateHasContentRoleAttribute( ...args ); +}; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index d7e8b191229893..449abca7b6420c 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,34 @@ ## Unreleased +### Bug Fixes + +- `ToolsPanel`: atomic one-step state update when (un)registering panels ([#65564](https://github.com/WordPress/gutenberg/pull/65564)). +- `Navigator`: fix `isInitial` logic ([#65527](https://github.com/WordPress/gutenberg/pull/65527)). +- `ToggleGroupControl`: Fix arrow key navigation in RTL ([#65735](https://github.com/WordPress/gutenberg/pull/65735)). +- `ToggleGroupControl`: indicator doesn't jump around when the layout around it changes ([#65175](https://github.com/WordPress/gutenberg/pull/65175)). +- `Composite`: fix legacy support for the store prop ([#65821](https://github.com/WordPress/gutenberg/pull/65821)). +- `Composite`: make items tabbable if active element gets removed ([#65720](https://github.com/WordPress/gutenberg/pull/65720)). + +### Deprecations + +- `__experimentalBorderControl` can now be imported as a stable `BorderControl` ([#65475](https://github.com/WordPress/gutenberg/pull/65475)). +- `__experimentalBorderBoxControl` can now be imported as a stable `BorderBoxControl` ([#65586](https://github.com/WordPress/gutenberg/pull/65586)). +- `__experimentalNavigator*` components can now be imported as a stable `Navigator`. Similarly, the `__experimentalUseNavigator` hook can be imported as a stable `useNavigator` ([#65802](https://github.com/WordPress/gutenberg/pull/65802)). + +### Enhancements + +- `Tabs`: handle horizontal overflow and large tab lists gracefully ([#64371](https://github.com/WordPress/gutenberg/pull/64371)). +- `BorderControl`: promote to stable ([#65475](https://github.com/WordPress/gutenberg/pull/65475)). +- `BorderBoxControl`: promote to stable ([#65586](https://github.com/WordPress/gutenberg/pull/65586)). +- `MenuGroup`: Simplify the MenuGroup styles within dropdown menus. ([#65561](https://github.com/WordPress/gutenberg/pull/65561)). +- `DatePicker`: Use compact button size. ([#65653](https://github.com/WordPress/gutenberg/pull/65653)). +- `Navigator`: add support for exit animation ([#64777](https://github.com/WordPress/gutenberg/pull/64777)). +- `Guide`: Update finish button to use the new default size ([#65680](https://github.com/WordPress/gutenberg/pull/65680)). +- `BorderControl`: Use `__next40pxDefaultSize` prop for Reset button ([#65682](https://github.com/WordPress/gutenberg/pull/65682)). +- `Navigator`: stabilize APIs ([#64613](https://github.com/WordPress/gutenberg/pull/64613)). +- `ToggleGroupControl`: indicator animation is now more lightweight and performant ([#65175](https://github.com/WordPress/gutenberg/pull/65175)). + ## 28.8.0 (2024-09-19) ### Bug Fixes @@ -9,11 +37,13 @@ - `Tabs`: restore vertical indicator ([#65385](https://github.com/WordPress/gutenberg/pull/65385)). - `Tabs`: indicator positioning under RTL direction ([#64926](https://github.com/WordPress/gutenberg/pull/64926)). - `Popover`: Update `toolbar` variant radius to match block toolbar ([#65263](https://github.com/WordPress/gutenberg/pull/65263)). +- `MenuItemsChoice`: Allow menu items height to adapt to its content ([#65204](https://github.com/WordPress/gutenberg/pull/65204)). - `BoxControl`: Unify input filed width whether linked or not ([#65348](https://github.com/WordPress/gutenberg/pull/65348)). ### Deprecations - Deprecate `__unstableComposite`, `__unstableCompositeGroup`, `__unstableCompositeItem` and `__unstableUseCompositeState`. Consumers of the package should use the stable `Composite` component instead ([#63572](https://github.com/WordPress/gutenberg/pull/63572)). +- `__experimentalBoxControl` can now be imported as a stable `BoxControl` ([#65469](https://github.com/WordPress/gutenberg/pull/65469)). ### New Features @@ -34,6 +64,7 @@ - `Tooltip`: Adopt elevation scale ([#65159](https://github.com/WordPress/gutenberg/pull/65159)). - `Modal`: add exit animation for internally triggered events ([#65203](https://github.com/WordPress/gutenberg/pull/65203)). - `Card`: Adopt radius scale ([#65053](https://github.com/WordPress/gutenberg/pull/65053)). +- `BoxControl`: promote to stable ([#65469](https://github.com/WordPress/gutenberg/pull/65469)). ### Bug Fixes diff --git a/packages/components/src/autocomplete/index.tsx b/packages/components/src/autocomplete/index.tsx index ef0fefe199c2e3..ad930d3affdd14 100644 --- a/packages/components/src/autocomplete/index.tsx +++ b/packages/components/src/autocomplete/index.tsx @@ -72,6 +72,9 @@ const getNodeText = ( node: React.ReactNode ): string => { const EMPTY_FILTERED_OPTIONS: KeyedOption[] = []; +// Used for generating the instance ID +const AUTOCOMPLETE_HOOK_REFERENCE = {}; + export function useAutocomplete( { record, onChange, @@ -79,7 +82,7 @@ export function useAutocomplete( { completers, contentRef, }: UseAutocompleteProps ) { - const instanceId = useInstanceId( useAutocomplete ); + const instanceId = useInstanceId( AUTOCOMPLETE_HOOK_REFERENCE ); const [ selectedIndex, setSelectedIndex ] = useState( 0 ); const [ filteredOptions, setFilteredOptions ] = useState< diff --git a/packages/components/src/border-box-control/border-box-control/README.md b/packages/components/src/border-box-control/border-box-control/README.md index 5ec2263bf16741..e67a1386103c1a 100644 --- a/packages/components/src/border-box-control/border-box-control/README.md +++ b/packages/components/src/border-box-control/border-box-control/README.md @@ -1,12 +1,7 @@ # BorderBoxControl -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
-
- -This component provides users with the ability to configure a single "flat" -border or separate borders per side. +An input control for the color, style, and width of the border of a box. The +border can be customized as a whole, or individually for each side of the box. ## Development guidelines @@ -28,7 +23,7 @@ show "Mixed" placeholder text. ```jsx import { useState } from 'react'; -import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components'; +import { BorderBoxControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; const colors = [ @@ -76,35 +71,35 @@ colors are organized by multiple origins. Each color may be an object containing a `name` and `color` value. -- Required: No -- Default: `[]` +- Required: No +- Default: `[]` ### `disableCustomColors`: `boolean` This toggles the ability to choose custom colors. -- Required: No +- Required: No ### `enableAlpha`: `boolean` This controls whether the alpha channel will be offered when selecting custom colors. -- Required: No -- Default: `false` +- Required: No +- Default: `false` ### `enableStyle`: `boolean` This controls whether to support border style selections. -- Required: No -- Default: `true` +- Required: No +- Default: `true` ### `hideLabelFromVision`: `boolean` Provides control over whether the label will only be visible to screen readers. -- Required: No +- Required: No ### `label`: `string` @@ -113,7 +108,7 @@ If provided, a label will be generated using this as the content. _Whether it is visible only to screen readers is controlled via `hideLabelFromVision`._ -- Required: No +- Required: No ### `onChange`: `( value?: Object ) => void` @@ -123,7 +118,7 @@ borders, or `undefined`. _Note: The will be `undefined` if a user clears all borders._ -- Required: Yes +- Required: Yes ### `popoverPlacement`: `string` @@ -133,21 +128,21 @@ By default, popovers are displayed relative to the button that initiated the pop The available base placements are 'top', 'right', 'bottom', 'left'. Each of these base placements has an alignment in the form -start and -end. For example, 'right-start', or 'bottom-end'. These allow you to align the tooltip to the edges of the button, rather than centering it. -- Required: No +- Required: No ### `popoverOffset`: `number` The space between the popover and the control wrapper. -- Required: No +- Required: No ### `size`: `string` Size of the control. -- Required: No -- Default: `default` -- Allowed values: `default`, `__unstable-large` +- Required: No +- Default: `default` +- Allowed values: `default`, `__unstable-large` ### `value`: `Object` @@ -158,6 +153,7 @@ properties or a "split" border which defines the previous properties but for each side; `top`, `right`, `bottom`, and `left`. Examples: + ```js const flatBorder = { color: '#72aee6', style: 'solid', width: '1px' }; const splitBorders = { @@ -168,11 +164,11 @@ const splitBorders = { }; ``` -- Required: No +- Required: No ### `__next40pxDefaultSize`: `boolean` Start opting into the larger default height that will become the default size in a future version. -- Required: No -- Default: `false` +- Required: No +- Default: `false` diff --git a/packages/components/src/border-box-control/border-box-control/component.tsx b/packages/components/src/border-box-control/border-box-control/component.tsx index 26967ad7f63ddb..1dd3437aa50de4 100644 --- a/packages/components/src/border-box-control/border-box-control/component.tsx +++ b/packages/components/src/border-box-control/border-box-control/component.tsx @@ -147,22 +147,11 @@ const UnconnectedBorderBoxControl = ( }; /** - * The `BorderBoxControl` effectively has two view states. The first, a "linked" - * view, allows configuration of a flat border via a single `BorderControl`. - * The second, a "split" view, contains a `BorderControl` for each side - * as well as a visualizer for the currently selected borders. Each view also - * contains a button to toggle between the two. - * - * When switching from the "split" view to "linked", if the individual side - * borders are not consistent, the "linked" view will display any border - * properties selections that are consistent while showing a mixed state for - * those that aren't. For example, if all borders had the same color and style - * but different widths, then the border dropdown in the "linked" view's - * `BorderControl` would show that consistent color and style but the "linked" - * view's width input would show "Mixed" placeholder text. + * An input control for the color, style, and width of the border of a box. The + * border can be customized as a whole, or individually for each side of the box. * * ```jsx - * import { __experimentalBorderBoxControl as BorderBoxControl } from '@wordpress/components'; + * import { BorderBoxControl } from '@wordpress/components'; * import { __ } from '@wordpress/i18n'; * * const colors = [ diff --git a/packages/components/src/border-box-control/stories/index.story.tsx b/packages/components/src/border-box-control/stories/index.story.tsx index 5b5d7f311208c0..5341dacab646eb 100644 --- a/packages/components/src/border-box-control/stories/index.story.tsx +++ b/packages/components/src/border-box-control/stories/index.story.tsx @@ -16,7 +16,7 @@ import Button from '../../button'; import { BorderBoxControl } from '../'; const meta: Meta< typeof BorderBoxControl > = { - title: 'Components (Experimental)/BorderBoxControl', + title: 'Components/BorderBoxControl', component: BorderBoxControl, argTypes: { onChange: { action: 'onChange' }, @@ -83,4 +83,5 @@ export const Default = Template.bind( {} ); Default.args = { colors, label: 'Borders', + enableStyle: true, }; diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx index b2951054e624e7..0223de66a4c78b 100644 --- a/packages/components/src/border-control/border-control-dropdown/component.tsx +++ b/packages/components/src/border-control/border-control-dropdown/component.tsx @@ -7,7 +7,6 @@ import type { CSSProperties } from 'react'; * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { closeSmall } from '@wordpress/icons'; /** * Internal dependencies @@ -17,12 +16,10 @@ import Button from '../../button'; import ColorIndicator from '../../color-indicator'; import ColorPalette from '../../color-palette'; import Dropdown from '../../dropdown'; -import { HStack } from '../../h-stack'; import { VStack } from '../../v-stack'; import type { WordPressComponentProps } from '../../context'; import { contextConnect } from '../../context'; import { useBorderControlDropdown } from './hook'; -import { StyledLabel } from '../../base-control/styles/base-control-styles'; import DropdownContentWrapper from '../../dropdown/dropdown-content-wrapper'; import type { ColorObject } from '../../color-palette/types'; @@ -149,7 +146,6 @@ const BorderControlDropdown = ( popoverContentClassName, popoverControlsClassName, resetButtonClassName, - showDropdownHeader, size, __unstablePopoverProps, ...otherProps @@ -197,17 +193,6 @@ const BorderControlDropdown = ( <> - { showDropdownHeader ? ( - - { __( 'Border color' ) } - diff --git a/packages/components/src/border-control/border-control/README.md b/packages/components/src/border-control/border-control/README.md index 74a212d00026bd..fbd0c10e418d5a 100644 --- a/packages/components/src/border-control/border-control/README.md +++ b/packages/components/src/border-control/border-control/README.md @@ -1,10 +1,6 @@ -# BorderControl +# BorderControl -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
-
-This component provides control over a border's color, style, and width. +An input control for a border's color, style, and width. ## Development guidelines @@ -21,7 +17,7 @@ a "shape" abstraction. ```jsx import { useState } from 'react'; -import { __experimentalBorderControl as BorderControl } from '@wordpress/components'; +import { BorderControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; const colors = [ @@ -58,41 +54,41 @@ colors are organized by multiple origins. Each color may be an object containing a `name` and `color` value. -- Required: No -- Default: `[]` +- Required: No +- Default: `[]` ### `disableCustomColors`: `boolean` This toggles the ability to choose custom colors. -- Required: No +- Required: No ### `disableUnits`: `boolean` This controls whether unit selection should be disabled. -- Required: No +- Required: No ### `enableAlpha`: `boolean` This controls whether the alpha channel will be offered when selecting custom colors. -- Required: No -- Default: `false` +- Required: No +- Default: `true` ### `enableStyle`: `boolean` This controls whether to support border style selection. -- Required: No -- Default: `true` +- Required: No +- Default: `true` ### `hideLabelFromVision`: `boolean` Provides control over whether the label will only be visible to screen readers. -- Required: No +- Required: No ### `isCompact`: `boolean` @@ -100,7 +96,7 @@ This flags the `BorderControl` to render with a more compact appearance. It restricts the width of the control and prevents it from expanding to take up additional space. -- Required: No +- Required: No ### `label`: `string` @@ -109,7 +105,7 @@ If provided, a label will be generated using this as the content. _Whether it is visible only to screen readers is controlled via `hideLabelFromVision`._ -- Required: No +- Required: No ### `onChange`: `( value?: Object ) => void` @@ -118,7 +114,7 @@ that selects or clears, border color, style, or width. _Note: the value may be `undefined` if a user clears all border properties._ -- Required: Yes +- Required: Yes ### `shouldSanitizeBorder`: `boolean` @@ -126,23 +122,16 @@ If opted into, sanitizing the border means that if no width or color have been selected, the border style is also cleared and `undefined` is returned as the new border value. -- Required: No -- Default: true - -### `showDropdownHeader`: `boolean` - -Whether or not to render a header for the border color and style picker -dropdown. The header includes a label for the color picker and a close button. - -- Required: No +- Required: No +- Default: `true` ### `size`: `string` Size of the control. -- Required: No -- Default: `default` -- Allowed values: `default`, `__unstable-large` +- Required: No +- Default: `default` +- Allowed values: `default`, `__unstable-large` ### `value`: `Object` @@ -150,6 +139,7 @@ An object representing a border or `undefined`. Used to set the current border configuration for this component. Example: + ```js { color: '#72aee6', @@ -158,25 +148,25 @@ Example: } ``` -- Required: No +- Required: No ### `width`: `CSSProperties[ 'width' ]` Controls the visual width of the `BorderControl`. It has no effect if the `isCompact` prop is set to `true`. -- Required: No +- Required: No ### `withSlider`: `boolean` Flags whether this `BorderControl` should also render a `RangeControl` for additional control over a border's width. -- Required: No +- Required: No ### `__next40pxDefaultSize`: `boolean` Start opting into the larger default height that will become the default size in a future version. -- Required: No -- Default: `false` +- Required: No +- Default: `false` diff --git a/packages/components/src/border-control/border-control/component.tsx b/packages/components/src/border-control/border-control/component.tsx index e2c96eaa9ffc0d..21be22c9dd55d8 100644 --- a/packages/components/src/border-control/border-control/component.tsx +++ b/packages/components/src/border-control/border-control/component.tsx @@ -91,7 +91,6 @@ const UnconnectedBorderControl = ( previousStyleSelection={ previousStyleSelection } - showDropdownHeader={ showDropdownHeader } __experimentalIsRenderedInSidebar={ __experimentalIsRenderedInSidebar } @@ -141,7 +140,7 @@ const UnconnectedBorderControl = ( * a "shape" abstraction. * * ```jsx - * import { __experimentalBorderControl as BorderControl } from '@wordpress/components'; + * import { BorderControl } from '@wordpress/components'; * import { __ } from '@wordpress/i18n'; * * const colors = [ diff --git a/packages/components/src/border-control/stories/index.story.tsx b/packages/components/src/border-control/stories/index.story.tsx index 9a5349d302c276..0756a18ac5c0e5 100644 --- a/packages/components/src/border-control/stories/index.story.tsx +++ b/packages/components/src/border-control/stories/index.story.tsx @@ -16,7 +16,7 @@ import { BorderControl } from '..'; import type { Border } from '../types'; const meta: Meta< typeof BorderControl > = { - title: 'Components (Experimental)/BorderControl', + title: 'Components/BorderControl', component: BorderControl, argTypes: { onChange: { @@ -93,6 +93,9 @@ export const Default = Template.bind( {} ); Default.args = { colors, label: 'Border', + enableAlpha: true, + enableStyle: true, + shouldSanitizeBorder: true, }; /** @@ -133,12 +136,3 @@ WithMultipleOrigins.args = { ...Default.args, colors: multipleOriginColors, }; - -/** - * Allow the alpha channel to be edited on each color. - */ -export const WithAlphaEnabled = Template.bind( {} ); -WithAlphaEnabled.args = { - ...Default.args, - enableAlpha: true, -}; diff --git a/packages/components/src/border-control/styles.ts b/packages/components/src/border-control/styles.ts index 2c77a2d21465d6..a678b6f362308a 100644 --- a/packages/components/src/border-control/styles.ts +++ b/packages/components/src/border-control/styles.ts @@ -156,7 +156,6 @@ export const resetButton = css` border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 400 ] }; border-top-left-radius: 0; border-top-right-radius: 0; - height: 40px; } `; diff --git a/packages/components/src/border-control/test/index.js b/packages/components/src/border-control/test/index.js index c41dce687cc522..000a89e14a40b3 100644 --- a/packages/components/src/border-control/test/index.js +++ b/packages/components/src/border-control/test/index.js @@ -148,19 +148,6 @@ describe( 'BorderControl', () => { expect( resetButton ).toBeInTheDocument(); } ); - it( 'should render color and style popover header', async () => { - const user = userEvent.setup(); - const props = createProps( { showDropdownHeader: true } ); - render( ); - await openPopover( user ); - - const headerLabel = screen.getByText( 'Border color' ); - const closeButton = getButton( 'Close border color' ); - - expect( headerLabel ).toBeInTheDocument(); - expect( closeButton ).toBeInTheDocument(); - } ); - it( 'should not render style options when opted out of', async () => { const user = userEvent.setup(); const props = createProps( { enableStyle: false } ); @@ -346,10 +333,10 @@ describe( 'BorderControl', () => { it( 'should take no action when color and style popover is closed', async () => { const user = userEvent.setup(); - const props = createProps( { showDropdownHeader: true } ); + const props = createProps(); render( ); await openPopover( user ); - await user.click( getButton( 'Close border color' ) ); + await user.keyboard( 'Escape' ); expect( props.onChange ).not.toHaveBeenCalled(); } ); diff --git a/packages/components/src/border-control/types.ts b/packages/components/src/border-control/types.ts index 5e028050d8e18e..8ab614907684d2 100644 --- a/packages/components/src/border-control/types.ts +++ b/packages/components/src/border-control/types.ts @@ -18,12 +18,19 @@ export type Border = { export type ColorProps = Pick< ColorPaletteProps, - 'colors' | 'enableAlpha' | '__experimentalIsRenderedInSidebar' + 'colors' | '__experimentalIsRenderedInSidebar' > & { /** * This toggles the ability to choose custom colors. */ disableCustomColors?: boolean; + /** + * This controls whether the alpha channel will be offered when selecting + * custom colors. + * + * @default true + */ + enableAlpha?: boolean; }; export type LabelProps = { @@ -78,9 +85,8 @@ export type BorderControlProps = ColorProps & */ shouldSanitizeBorder?: boolean; /** - * Whether or not to show the header for the border color and style - * picker dropdown. The header includes a label for the color picker - * and a close button. + * @deprecated This prop no longer has any effect. + * @ignore */ showDropdownHeader?: boolean; /** @@ -139,9 +145,8 @@ export type DropdownProps = ColorProps & */ previousStyleSelection?: string; /** - * Whether or not to render a header for the border color and style picker - * dropdown. The header includes a label for the color picker and a - * close button. + * @deprecated This prop no longer has any effect. + * @ignore */ showDropdownHeader?: boolean; }; diff --git a/packages/components/src/box-control/README.md b/packages/components/src/box-control/README.md index b03b03a85466ae..77176b49eeb6d8 100644 --- a/packages/components/src/box-control/README.md +++ b/packages/components/src/box-control/README.md @@ -1,18 +1,14 @@ # BoxControl -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -BoxControl components let users set values for Top, Right, Bottom, and Left. This can be used as an input control for values like `padding` or `margin`. +A control that lets users set values for top, right, bottom, and left. Can be used as an input control for values like `padding` or `margin`. ## Usage ```jsx import { useState } from 'react'; -import { __experimentalBoxControl as BoxControl } from '@wordpress/components'; +import { BoxControl } from '@wordpress/components'; -const Example = () => { +function Example() { const [ values, setValues ] = useState( { top: '50px', left: '10%', @@ -26,23 +22,24 @@ const Example = () => { onChange={ ( nextValues ) => setValues( nextValues ) } /> ); -}; +} ``` ## Props + ### `allowReset`: `boolean` If this property is true, a button to reset the box control is rendered. -- Required: No -- Default: `true` +- Required: No +- Default: `true` ### `splitOnAxis`: `boolean` If this property is true, when the box control is unlinked, vertical and horizontal controls can be used instead of updating individual sides. -- Required: No -- Default: `false` +- Required: No +- Default: `false` ### `inputProps`: `object` diff --git a/packages/components/src/box-control/index.tsx b/packages/components/src/box-control/index.tsx index 9c3452d4ccb806..41e95aa88bea37 100644 --- a/packages/components/src/box-control/index.tsx +++ b/packages/components/src/box-control/index.tsx @@ -47,14 +47,14 @@ function useUniqueId( idProp?: string ) { } /** - * BoxControl components let users set values for Top, Right, Bottom, and Left. - * This can be used as an input control for values like `padding` or `margin`. + * A control that lets users set values for top, right, bottom, and left. Can be + * used as an input control for values like `padding` or `margin`. * * ```jsx - * import { __experimentalBoxControl as BoxControl } from '@wordpress/components'; + * import { BoxControl } from '@wordpress/components'; * import { useState } from '@wordpress/element'; * - * const Example = () => { + * function Example() { * const [ values, setValues ] = useState( { * top: '50px', * left: '10%', diff --git a/packages/components/src/box-control/stories/index.story.tsx b/packages/components/src/box-control/stories/index.story.tsx index 1b6604048f6d52..783f9d047b1bb0 100644 --- a/packages/components/src/box-control/stories/index.story.tsx +++ b/packages/components/src/box-control/stories/index.story.tsx @@ -14,7 +14,7 @@ import { useState } from '@wordpress/element'; import BoxControl from '../'; const meta: Meta< typeof BoxControl > = { - title: 'Components (Experimental)/BoxControl', + title: 'Components/BoxControl', component: BoxControl, argTypes: { values: { control: { type: null } }, diff --git a/packages/components/src/box-control/types.ts b/packages/components/src/box-control/types.ts index eeb72df14bb9c1..5f4071aeed88a7 100644 --- a/packages/components/src/box-control/types.ts +++ b/packages/components/src/box-control/types.ts @@ -37,13 +37,13 @@ export type BoxControlProps = Pick< /** * Props for the internal `UnitControl` components. * - * @default `{ min: 0 }` + * @default { min: 0 } */ inputProps?: UnitControlPassthroughProps; /** * Heading label for the control. * - * @default `__( 'Box Control' )` + * @default __( 'Box Control' ) */ label?: string; /** @@ -53,7 +53,7 @@ export type BoxControlProps = Pick< /** * The `top`, `right`, `bottom`, and `left` box dimension values to use when the control is reset. * - * @default `{ top: undefined, right: undefined, bottom: undefined, left: undefined }` + * @default { top: undefined, right: undefined, bottom: undefined, left: undefined } */ resetValues?: BoxControlValue; /** diff --git a/packages/components/src/composite/group-label.tsx b/packages/components/src/composite/group-label.tsx index 17070dbb86bf81..7e3c6ffdc7759c 100644 --- a/packages/components/src/composite/group-label.tsx +++ b/packages/components/src/composite/group-label.tsx @@ -20,11 +20,13 @@ export const CompositeGroupLabel = forwardRef< WordPressComponentProps< CompositeGroupLabelProps, 'div', false > >( function CompositeGroupLabel( props, ref ) { const context = useCompositeContext(); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + return ( - + ); } ); diff --git a/packages/components/src/composite/group.tsx b/packages/components/src/composite/group.tsx index ae21ca6f11dd92..bcfb47e684613d 100644 --- a/packages/components/src/composite/group.tsx +++ b/packages/components/src/composite/group.tsx @@ -20,11 +20,11 @@ export const CompositeGroup = forwardRef< WordPressComponentProps< CompositeGroupProps, 'div', false > >( function CompositeGroup( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/composite/hover.tsx b/packages/components/src/composite/hover.tsx index ca0bd9d8f6aa12..1507a1879cc19f 100644 --- a/packages/components/src/composite/hover.tsx +++ b/packages/components/src/composite/hover.tsx @@ -20,11 +20,11 @@ export const CompositeHover = forwardRef< WordPressComponentProps< CompositeHoverProps, 'div', false > >( function CompositeHover( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/composite/index.tsx b/packages/components/src/composite/index.tsx index e9e97072261fbf..8eb562f5bdab38 100644 --- a/packages/components/src/composite/index.tsx +++ b/packages/components/src/composite/index.tsx @@ -73,7 +73,10 @@ export const Composite = Object.assign( }, ref ) { - const store = Ariakit.useCompositeStore( { + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. + const storeProp = props.store as Ariakit.CompositeStore; + const internalStore = Ariakit.useCompositeStore( { activeId, defaultActiveId, setActiveId, @@ -85,6 +88,8 @@ export const Composite = Object.assign( rtl, } ); + const store = storeProp ?? internalStore; + const contextValue = useMemo( () => ( { store, diff --git a/packages/components/src/composite/item.tsx b/packages/components/src/composite/item.tsx index 6d75b90f0baaaa..edbf0b92e039af 100644 --- a/packages/components/src/composite/item.tsx +++ b/packages/components/src/composite/item.tsx @@ -20,9 +20,27 @@ export const CompositeItem = forwardRef< WordPressComponentProps< CompositeItemProps, 'button', false > >( function CompositeItem( props, ref ) { const context = useCompositeContext(); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + // If the active item is not connected, Composite may end up in a state + // where none of the items are tabbable. In this case, we force all items to + // be tabbable, so that as soon as an item received focus, it becomes active + // and Composite goes back to working as expected. + const tabbable = Ariakit.useStoreState( store, ( state ) => { + return ( + state?.activeId !== null && + ! store?.item( state?.activeId )?.element?.isConnected + ); + } ); + return ( diff --git a/packages/components/src/composite/legacy/test/index.tsx b/packages/components/src/composite/legacy/test/index.tsx index c034d31442ca8d..a118dbcfbadbb3 100644 --- a/packages/components/src/composite/legacy/test/index.tsx +++ b/packages/components/src/composite/legacy/test/index.tsx @@ -232,7 +232,7 @@ describe.each( [ ); - renderAndValidate( ); + await renderAndValidate( ); await press.Tab(); expect( screen.getByText( 'Before' ) ).toHaveFocus(); @@ -260,7 +260,7 @@ describe.each( [ ); }; - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); @@ -289,7 +289,7 @@ describe.each( [ ); }; - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); expect( item2 ).toBeEnabled(); @@ -310,7 +310,7 @@ describe.each( [ } ) } /> ); - renderAndValidate( ); + await renderAndValidate( ); const { item1, item2, item3 } = getOneDimensionalItems(); expect( item1.id ).toMatch( 'test-id-1' ); @@ -327,7 +327,7 @@ describe.each( [ } ) } /> ); - renderAndValidate( ); + await renderAndValidate( ); const { item2 } = getOneDimensionalItems(); await press.Tab(); @@ -341,37 +341,37 @@ describe.each( [ ] )( '%s', ( _when, rtl ) => { const { previous, next, first, last } = getKeys( rtl ); - function useOneDimensionalTest( initialState?: InitialState ) { + async function useOneDimensionalTest( initialState?: InitialState ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getOneDimensionalItems(); } - function useTwoDimensionalTest( initialState?: InitialState ) { + async function useTwoDimensionalTest( initialState?: InitialState ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getTwoDimensionalItems(); } - function useShiftTest( shift: boolean ) { + async function useShiftTest( shift: boolean ) { const Test = () => ( ); - renderAndValidate( ); + await renderAndValidate( ); return getShiftTestItems(); } describe( 'In one dimension', () => { test( 'All directions work with no orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest(); + const { item1, item2, item3 } = await useOneDimensionalTest(); await press.Tab(); expect( item1 ).toHaveFocus(); @@ -406,7 +406,7 @@ describe.each( [ } ); test( 'Only left/right work with horizontal orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { orientation: 'horizontal', } ); @@ -435,7 +435,7 @@ describe.each( [ } ); test( 'Only up/down work with vertical orientation', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { orientation: 'vertical', } ); @@ -464,7 +464,7 @@ describe.each( [ } ); test( 'Focus wraps with loop enabled', async () => { - const { item1, item2, item3 } = useOneDimensionalTest( { + const { item1, item2, item3 } = await useOneDimensionalTest( { loop: true, } ); @@ -488,7 +488,7 @@ describe.each( [ describe( 'In two dimensions', () => { test( 'All directions work as standard', async () => { const { itemA1, itemA2, itemA3, itemB1, itemB2, itemC1, itemC3 } = - useTwoDimensionalTest(); + await useTwoDimensionalTest(); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -524,7 +524,7 @@ describe.each( [ test( 'Focus wraps around rows/columns with loop enabled', async () => { const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } = - useTwoDimensionalTest( { loop: true } ); + await useTwoDimensionalTest( { loop: true } ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -548,7 +548,7 @@ describe.each( [ test( 'Focus moves between rows/columns with wrap enabled', async () => { const { itemA1, itemA2, itemA3, itemB1, itemC1, itemC3 } = - useTwoDimensionalTest( { wrap: true } ); + await useTwoDimensionalTest( { wrap: true } ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -577,7 +577,7 @@ describe.each( [ } ); test( 'Focus wraps around start/end with loop and wrap enabled', async () => { - const { itemA1, itemC3 } = useTwoDimensionalTest( { + const { itemA1, itemC3 } = await useTwoDimensionalTest( { loop: true, wrap: true, } ); @@ -595,7 +595,8 @@ describe.each( [ } ); test( 'Focus shifts if vertical neighbour unavailable when shift enabled', async () => { - const { itemA1, itemB1, itemB2, itemC1 } = useShiftTest( true ); + const { itemA1, itemB1, itemB2, itemC1 } = + await useShiftTest( true ); await press.Tab(); expect( itemA1 ).toHaveFocus(); @@ -616,7 +617,7 @@ describe.each( [ } ); test( 'Focus does not shift if vertical neighbour unavailable when shift not enabled', async () => { - const { itemA1, itemB1, itemB2 } = useShiftTest( false ); + const { itemA1, itemB1, itemB2 } = await useShiftTest( false ); await press.Tab(); expect( itemA1 ).toHaveFocus(); diff --git a/packages/components/src/composite/row.tsx b/packages/components/src/composite/row.tsx index a082af03ad6785..1a88da557785e9 100644 --- a/packages/components/src/composite/row.tsx +++ b/packages/components/src/composite/row.tsx @@ -20,11 +20,11 @@ export const CompositeRow = forwardRef< WordPressComponentProps< CompositeRowProps, 'div', false > >( function CompositeRow( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/composite/stories/index.story.tsx b/packages/components/src/composite/stories/index.story.tsx index d6e4999407e993..c5518375df8a6f 100644 --- a/packages/components/src/composite/stories/index.story.tsx +++ b/packages/components/src/composite/stories/index.story.tsx @@ -13,6 +13,7 @@ import { useContext, useMemo } from '@wordpress/element'; */ import { createSlotFill, Provider as SlotFillProvider } from '../../slot-fill'; import { Composite } from '..'; +import { Tooltip } from '../../tooltip'; const meta: Meta< typeof Composite > = { title: 'Components/Composite', @@ -353,3 +354,44 @@ const Fill = ( { children } ) => { }, }, }; + +/** + * Combining the `Tooltip` and `Composite` component has a few caveats. And while there are a few ways to compose these two components, our recommendation is to render `Composite.Item` as a child of `Tooltip`. + * + * ```jsx + * // šŸ”“ Does not work + * + * + * + * } + * /> + * + * // šŸŸ¢ Good + * + * + * Item one + * + * + * ``` + */ +export const WithTooltips: StoryObj< typeof Composite > = { + ...Default, + args: { + ...Default.args, + children: ( + <> + + Item one + + + Item two + + + Item three + + + ), + }, +}; diff --git a/packages/components/src/composite/test/index.tsx b/packages/components/src/composite/test/index.tsx new file mode 100644 index 00000000000000..64619aaed01bd6 --- /dev/null +++ b/packages/components/src/composite/test/index.tsx @@ -0,0 +1,123 @@ +/** + * External dependencies + */ +import { queryByAttribute, render, screen } from '@testing-library/react'; +import { click, press, waitFor } from '@ariakit/test'; +import type { ComponentProps } from 'react'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Composite } from '..'; + +// This is necessary because of how Ariakit calculates page up and +// page down. Without this, nothing has a height, and so paging up +// and down doesn't behave as expected in tests. + +let clientHeightSpy: jest.SpiedGetter< + typeof HTMLElement.prototype.clientHeight +>; + +beforeAll( () => { + clientHeightSpy = jest + .spyOn( HTMLElement.prototype, 'clientHeight', 'get' ) + .mockImplementation( function getClientHeight( this: HTMLElement ) { + if ( this.tagName === 'BODY' ) { + return window.outerHeight; + } + return 50; + } ); +} ); + +afterAll( () => { + clientHeightSpy?.mockRestore(); +} ); + +async function renderAndValidate( ...args: Parameters< typeof render > ) { + const view = render( ...args ); + await waitFor( () => { + const activeButton = queryByAttribute( + 'data-active-item', + view.baseElement, + 'true' + ); + expect( activeButton ).not.toBeNull(); + } ); + return view; +} + +function RemoveItemTest( props: ComponentProps< typeof Composite > ) { + const [ showThirdItem, setShowThirdItem ] = useState( true ); + return ( + <> + + + Item 1 + Item 2 + { showThirdItem && Item 3 } + + + + ); +} + +describe( 'Composite', () => { + it( 'should remain focusable even when there are no elements in the DOM associated with the currently active ID', async () => { + await renderAndValidate( ); + + const toggleButton = screen.getByRole( 'button', { + name: 'Toggle third item', + } ); + + await press.Tab(); + await press.Tab(); + + expect( + screen.getByRole( 'button', { name: 'Item 1' } ) + ).toHaveFocus(); + + await press.ArrowRight(); + await press.ArrowRight(); + + expect( + screen.getByRole( 'button', { name: 'Item 3' } ) + ).toHaveFocus(); + + await click( toggleButton ); + + expect( + screen.queryByRole( 'button', { name: 'Item 3' } ) + ).not.toBeInTheDocument(); + + await press.ShiftTab(); + + expect( + screen.getByRole( 'button', { name: 'Item 2' } ) + ).toHaveFocus(); + + await click( toggleButton ); + + expect( + screen.getByRole( 'button', { name: 'Item 3' } ) + ).toBeVisible(); + + await press.ShiftTab(); + + expect( + screen.getByRole( 'button', { name: 'Item 2' } ) + ).toHaveFocus(); + + await press.ArrowRight(); + + expect( + screen.getByRole( 'button', { name: 'Item 3' } ) + ).toHaveFocus(); + } ); +} ); diff --git a/packages/components/src/composite/typeahead.tsx b/packages/components/src/composite/typeahead.tsx index 771d58bcb6c25c..519c59ea374e5d 100644 --- a/packages/components/src/composite/typeahead.tsx +++ b/packages/components/src/composite/typeahead.tsx @@ -20,11 +20,11 @@ export const CompositeTypeahead = forwardRef< WordPressComponentProps< CompositeTypeaheadProps, 'div', false > >( function CompositeTypeahead( props, ref ) { const context = useCompositeContext(); - return ( - - ); + + // @ts-expect-error The store prop is undocumented and only used by the + // legacy compat layer. The `store` prop is documented, but its type is + // obfuscated to discourage its use outside of the component's internals. + const store = ( props.store ?? context.store ) as Ariakit.CompositeStore; + + return ; } ); diff --git a/packages/components/src/date-time/date/index.tsx b/packages/components/src/date-time/date/index.tsx index 33fc736564d5e6..5a565ee38cec59 100644 --- a/packages/components/src/date-time/date/index.tsx +++ b/packages/components/src/date-time/date/index.tsx @@ -125,6 +125,7 @@ export function DatePicker( { ) ); } } + size="compact" /> @@ -150,6 +151,7 @@ export function DatePicker( { ) ); } } + size="compact" /> = { icon: more, children: ( { onClose } ) => ( <> + + Standalone Item + Move Up diff --git a/packages/components/src/dropdown/stories/index.story.tsx b/packages/components/src/dropdown/stories/index.story.tsx index c6fe5014ffdc2a..0f07664787cc33 100644 --- a/packages/components/src/dropdown/stories/index.story.tsx +++ b/packages/components/src/dropdown/stories/index.story.tsx @@ -99,6 +99,7 @@ export const WithMenuItems: StoryObj< typeof Dropdown > = { ...Default.args, renderContent: () => ( <> + Standalone Item Item 1 Item 2 diff --git a/packages/components/src/dropdown/style.scss b/packages/components/src/dropdown/style.scss index 8a5b0e0a0a6a28..d7ae7963f7ed8c 100644 --- a/packages/components/src/dropdown/style.scss +++ b/packages/components/src/dropdown/style.scss @@ -5,6 +5,16 @@ .components-dropdown__content { .components-popover__content { padding: $grid-unit-10; + + &:has(.components-menu-group) { + padding: 0; + + .components-dropdown-menu__menu > .components-menu-item__button, + > .components-menu-item__button { + margin: $grid-unit-10; + width: auto; + } + } } [role="menuitem"] { @@ -13,22 +23,9 @@ .components-menu-group { padding: $grid-unit-10; - margin-top: 0; - margin-bottom: 0; - margin-left: -$grid-unit-10; - margin-right: -$grid-unit-10; - - &:first-child { - margin-top: -$grid-unit-10; - } - - &:last-child { - margin-bottom: -$grid-unit-10; - } } .components-menu-group + .components-menu-group { - margin-top: 0; border-top: $border-width solid $gray-400; padding: $grid-unit-10; } diff --git a/packages/components/src/guide/index.tsx b/packages/components/src/guide/index.tsx index 0ca5957fd3a656..121c9f22330e88 100644 --- a/packages/components/src/guide/index.tsx +++ b/packages/components/src/guide/index.tsx @@ -164,6 +164,7 @@ function Guide( { className="components-guide__finish-button" variant="primary" onClick={ onFinish } + __next40pxDefaultSize > { finishButtonText } diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 32195ebc444ce6..e82d6da70279e8 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -33,14 +33,22 @@ export { } from './autocomplete'; export { default as BaseControl, useBaseControlProps } from './base-control'; export { + /** @deprecated Import `BorderBoxControl` instead. */ BorderBoxControl as __experimentalBorderBoxControl, + BorderBoxControl, hasSplitBorders as __experimentalHasSplitBorders, isDefinedBorder as __experimentalIsDefinedBorder, isEmptyBorder as __experimentalIsEmptyBorder, } from './border-box-control'; -export { BorderControl as __experimentalBorderControl } from './border-control'; export { + /** @deprecated Import `BorderControl` instead. */ + BorderControl as __experimentalBorderControl, + BorderControl, +} from './border-control'; +export { + /** @deprecated Import `BoxControl` instead. */ default as __experimentalBoxControl, + default as BoxControl, applyValueToSides as __experimentalApplyValueToSides, } from './box-control'; export { default as Button } from './button'; @@ -121,11 +129,21 @@ export { default as __experimentalNavigationGroup } from './navigation/group'; export { default as __experimentalNavigationItem } from './navigation/item'; export { default as __experimentalNavigationMenu } from './navigation/menu'; export { + /** @deprecated Import `Navigator` instead. */ NavigatorProvider as __experimentalNavigatorProvider, + /** @deprecated Import `Navigator` and use `Navigator.Screen` instead. */ NavigatorScreen as __experimentalNavigatorScreen, + /** @deprecated Import `Navigator` and use `Navigator.Button` instead. */ NavigatorButton as __experimentalNavigatorButton, + /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */ NavigatorBackButton as __experimentalNavigatorBackButton, + /** @deprecated Import `Navigator` and use `Navigator.BackButton` instead. */ NavigatorToParentButton as __experimentalNavigatorToParentButton, +} from './navigator/legacy'; +export { + Navigator, + useNavigator, + /** @deprecated Import `useNavigator` instead. */ useNavigator as __experimentalUseNavigator, } from './navigator'; export { default as Notice } from './notice'; diff --git a/packages/components/src/menu-group/style.scss b/packages/components/src/menu-group/style.scss index d9412c504940b3..744e3f74c5b955 100644 --- a/packages/components/src/menu-group/style.scss +++ b/packages/components/src/menu-group/style.scss @@ -1,5 +1,4 @@ .components-menu-group + .components-menu-group { - margin-top: $grid-unit-10; padding-top: $grid-unit-10; border-top: $border-width solid $gray-900; @@ -10,6 +9,10 @@ } } +.components-menu-group:has(> div:empty) { + display: none; +} + .components-menu-group__label { padding: 0 $grid-unit-10; margin-top: $grid-unit-05; diff --git a/packages/components/src/menu-items-choice/style.scss b/packages/components/src/menu-items-choice/style.scss index 5de8363be0d6e8..383eb4066ba86b 100644 --- a/packages/components/src/menu-items-choice/style.scss +++ b/packages/components/src/menu-items-choice/style.scss @@ -1,5 +1,7 @@ .components-menu-items-choice, .components-menu-items-choice.components-button { + height: auto; + svg { margin-right: $grid-unit-15; } diff --git a/packages/components/src/navigator/README.md b/packages/components/src/navigator/README.md new file mode 100644 index 00000000000000..b56a82e0524eef --- /dev/null +++ b/packages/components/src/navigator/README.md @@ -0,0 +1,176 @@ +# `Navigator` + +`Navigator` is a collection components that allow rendering nested views/panels/menus (via the `Navigator.Screen` component) and navigate between them (via the `Navigator.Button` and `Navigator.BackButton` components). + +## Usage + +```jsx +import { Navigator } from '@wordpress/components'; + +const MyNavigation = () => ( + + +

This is the home screen.

+ + Navigate to child screen. + +
+ +

This is the child screen.

+ Go back +
+
+); +``` + +### Hierarchical `path`s + +`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. + +`Navigator` will treat "back" navigations as going to the parent screen ā€” it is, therefore, the responsibility of the consumer of the component to create the correct screen hierarchy. + +For example: + +- `/` is the root of all paths. There should always be a screen with `path="/"`; +- `/parent/child` is a child of `/parent`; +- `/parent/child/grand-child` is a child of `/parent/child`; +- `/parent/:param` is a child of `/parent` as well; +- if the current screen has a `path="/parent/child/grand-child"`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. + +### Height and animations + +Due to how `Navigator.Screen` animations work, it is recommended that the `Navigator` component is assigned a `height` to prevent some potential UI jumps while moving across screens. + +### Individual components + +`Navigator` is comprised of four individual components: + +- `Navigator`: a wrapper component and context provider. It holds the main logic for hiding and showing screens. +- `Navigator.Screen`: represents a single view/screen/panel; +- `Navigator.Button`: renders a button that allows navigating to a different `Navigator.Screen`; +- `Navigator.BackButton`: renders a button that allows navigating to the parent `Navigator.Screen` (see the section above about hierarchical paths). + +For advanced usages, consumers can use the `useNavigator` hook. + +#### `Navigator` + +##### Props + +###### `initialPath`: `string` + +The initial active path. + +- Required: Yes + +###### `children`: `string` + +The children elements. + +- Required: Yes + +#### `Navigator.Screen` + +##### Props + +###### `path`: `string` + +The screen's path, matched against the current path stored in the navigator. + +`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. + +`Navigator` will treat "back" navigations as going to the parent screen ā€” it is, therefore, the responsibility of the consumer of the component to create the correct screen hierarchy. + +For example: + +- `/` is the root of all paths. There should always be a screen with `path="/"`. +- `/parent/child` is a child of `/parent`. +- `/parent/child/grand-child` is a child of `/parent/child`. +- `/parent/:param` is a child of `/parent` as well. +- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. + +- Required: Yes + +###### `children`: `string` + +The children elements. + +- Required: Yes + +#### `Navigator.Button` + +##### Props + +###### `path`: `string` + +The path of the screen to navigate to. The value of this prop needs to be [a valid value for an HTML attribute](https://html.spec.whatwg.org/multipage/syntax.html#attributes-2). + +- Required: Yes + +###### `attributeName`: `string` + +The HTML attribute used to identify the `Navigator.Button`, which is used by `Navigator` to restore focus. + +- Required: No +- Default: `id` + +###### `children`: `string` + +The children elements. + +- Required: No + +###### Inherited props + +`Navigator.Button` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. + +#### `Navigator.BackButton` + +##### Props + +###### `children`: `string` + +The children elements. + +- Required: No + +###### Inherited props + +`Navigator.BackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. + +#### `useNavigator` + +You can retrieve a `navigator` instance by using the `useNavigator` hook. + +##### Props + +The `navigator` instance has a few properties: + +###### `goTo`: `( path: string, options: NavigateOptions ) => void` + +The `goTo` function allows navigating to a given path. The second argument can augment the navigation operations with different options. + +The available options are: + +- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back; +- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too); +- `skipFocus`: `boolean`. An optional property used to opt out of `Navigator`'s focus management, useful when the consumer of the component wants to manage focus themselves; + +###### `goBack`: `( path: string, options: NavigateOptions ) => void` + +The `goBack` function allows navigating to the parent screen. Parent/child navigation only works if the paths you define are hierarchical (see note above). + +When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. + +The available options are the same as for the `goTo` method, except for the `isBack` property, which is not available for the `goBack` method. + +###### `location`: `NavigatorLocation` + +The `location` object represents the current location, and has a few properties: + +- `path`: `string`. The path associated to the location. +- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards. +- `isInitial`: `boolean`. A flag that is `true` only for the initial location. + +###### `params`: `Record< string, string | string[] >` + +The parsed record of parameters from the current location. For example if the current screen path is `/product/:productId` and the location is `/product/123`, then `params` will be `{ productId: '123' }`. diff --git a/packages/components/src/navigator/index.ts b/packages/components/src/navigator/index.ts deleted file mode 100644 index 130edc2ae52eb8..00000000000000 --- a/packages/components/src/navigator/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { NavigatorProvider } from './navigator-provider/component'; -export { NavigatorScreen } from './navigator-screen/component'; -export { NavigatorButton } from './navigator-button/component'; -export { NavigatorBackButton } from './navigator-back-button/component'; -export { NavigatorToParentButton } from './navigator-to-parent-button/component'; -export { useNavigator } from './use-navigator'; diff --git a/packages/components/src/navigator/index.tsx b/packages/components/src/navigator/index.tsx new file mode 100644 index 00000000000000..1d9ae95441e01a --- /dev/null +++ b/packages/components/src/navigator/index.tsx @@ -0,0 +1,131 @@ +/** + * Internal dependencies + */ +import { Navigator as TopLevelNavigator } from './navigator/component'; +import { NavigatorScreen } from './navigator-screen/component'; +import { NavigatorButton } from './navigator-button/component'; +import { NavigatorBackButton } from './navigator-back-button/component'; +export { useNavigator } from './use-navigator'; + +/** + * The `Navigator` component allows rendering nested views/panels/menus + * (via the `Navigator.Screen` component) and navigate between them + * (via the `Navigator.Button` and `Navigator.BackButton` components). + * + * ```jsx + * import { Navigator } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ +export const Navigator = Object.assign( TopLevelNavigator, { + /** + * The `Navigator.Screen` component represents a single view/screen/panel and + * should be used in combination with the `Navigator`, the `Navigator.Button` + * and the `Navigator.BackButton` components. + * + * @example + * ```jsx + * import { Navigator } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ + Screen: Object.assign( NavigatorScreen, { + displayName: 'Navigator.Screen', + } ), + /** + * The `Navigator.Button` component can be used to navigate to a screen and + * should be used in combination with the `Navigator`, the `Navigator.Screen` + * and the `Navigator.BackButton` components. + * + * @example + * ```jsx + * import { Navigator } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ + Button: Object.assign( NavigatorButton, { + displayName: 'Navigator.Button', + } ), + /** + * The `Navigator.BackButton` component can be used to navigate to a screen and + * should be used in combination with the `Navigator`, the `Navigator.Screen` + * and the `Navigator.Button` components. + * + * @example + * ```jsx + * import { Navigator } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ + BackButton: Object.assign( NavigatorBackButton, { + displayName: 'Navigator.BackButton', + } ), +} ); diff --git a/packages/components/src/navigator/legacy.ts b/packages/components/src/navigator/legacy.ts new file mode 100644 index 00000000000000..1caa5380fc049e --- /dev/null +++ b/packages/components/src/navigator/legacy.ts @@ -0,0 +1,169 @@ +/** + * Internal dependencies + */ +import { Navigator as InternalNavigator } from './navigator/component'; +import { NavigatorScreen as InternalNavigatorScreen } from './navigator-screen/component'; +import { NavigatorButton as InternalNavigatorButton } from './navigator-button/component'; +import { NavigatorBackButton as InternalNavigatorBackButton } from './navigator-back-button/component'; +import { NavigatorToParentButton as InternalNavigatorToParentButton } from './navigator-to-parent-button/component'; +export { useNavigator } from './use-navigator'; + +/** + * The `NavigatorProvider` component allows rendering nested views/panels/menus + * (via the `NavigatorScreen` component and navigate between them + * (via the `NavigatorButton` and `NavigatorBackButton` components). + * + * ```jsx + * import { + * __experimentalNavigatorProvider as NavigatorProvider, + * __experimentalNavigatorScreen as NavigatorScreen, + * __experimentalNavigatorButton as NavigatorButton, + * __experimentalNavigatorBackButton as NavigatorBackButton, + * } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ +export const NavigatorProvider = Object.assign( InternalNavigator, { + displayName: 'NavigatorProvider', +} ); + +/** + * The `NavigatorScreen` component represents a single view/screen/panel and + * should be used in combination with the `NavigatorProvider`, the + * `NavigatorButton` and the `NavigatorBackButton` components. + * + * @example + * ```jsx + * import { + * __experimentalNavigatorProvider as NavigatorProvider, + * __experimentalNavigatorScreen as NavigatorScreen, + * __experimentalNavigatorButton as NavigatorButton, + * __experimentalNavigatorBackButton as NavigatorBackButton, + * } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ +export const NavigatorScreen = Object.assign( InternalNavigatorScreen, { + displayName: 'NavigatorScreen', +} ); + +/** + * The `NavigatorButton` component can be used to navigate to a screen and should + * be used in combination with the `NavigatorProvider`, the `NavigatorScreen` + * and the `NavigatorBackButton` components. + * + * @example + * ```jsx + * import { + * __experimentalNavigatorProvider as NavigatorProvider, + * __experimentalNavigatorScreen as NavigatorScreen, + * __experimentalNavigatorButton as NavigatorButton, + * __experimentalNavigatorBackButton as NavigatorBackButton, + * } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back + * + *
+ *
+ * ); + * ``` + */ +export const NavigatorButton = Object.assign( InternalNavigatorButton, { + displayName: 'NavigatorButton', +} ); + +/** + * The `NavigatorBackButton` component can be used to navigate to a screen and + * should be used in combination with the `NavigatorProvider`, the + * `NavigatorScreen` and the `NavigatorButton` components. + * + * @example + * ```jsx + * import { + * __experimentalNavigatorProvider as NavigatorProvider, + * __experimentalNavigatorScreen as NavigatorScreen, + * __experimentalNavigatorButton as NavigatorButton, + * __experimentalNavigatorBackButton as NavigatorBackButton, + * } from '@wordpress/components'; + * + * const MyNavigation = () => ( + * + * + *

This is the home screen.

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

This is the child screen.

+ * + * Go back (to parent) + * + *
+ *
+ * ); + * ``` + */ +export const NavigatorBackButton = Object.assign( InternalNavigatorBackButton, { + displayName: 'NavigatorBackButton', +} ); + +/** + * _Note: this component is deprecated. Please use the `NavigatorBackButton` + * component instead._ + * + * @deprecated + */ +export const NavigatorToParentButton = Object.assign( + InternalNavigatorToParentButton, + { + displayName: 'NavigatorToParentButton', + } +); diff --git a/packages/components/src/navigator/navigator-back-button/README.md b/packages/components/src/navigator/navigator-back-button/README.md deleted file mode 100644 index 01d4221be536e5..00000000000000 --- a/packages/components/src/navigator/navigator-back-button/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# `NavigatorBackButton` - -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -The `NavigatorBackButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook). - -## Usage - -Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example. - -### Inherited props - -`NavigatorBackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. diff --git a/packages/components/src/navigator/navigator-back-button/component.tsx b/packages/components/src/navigator/navigator-back-button/component.tsx index 88ed45b643a13d..b5c4de7df78a85 100644 --- a/packages/components/src/navigator/navigator-back-button/component.tsx +++ b/packages/components/src/navigator/navigator-back-button/component.tsx @@ -21,43 +21,7 @@ function UnconnectedNavigatorBackButton( return ; } -/** - * The `NavigatorBackButton` component can be used to navigate to a screen and - * should be used in combination with the `NavigatorProvider`, the - * `NavigatorScreen` and the `NavigatorButton` components (or the `useNavigator` - * hook). - * - * @example - * ```jsx - * import { - * __experimentalNavigatorProvider as NavigatorProvider, - * __experimentalNavigatorScreen as NavigatorScreen, - * __experimentalNavigatorButton as NavigatorButton, - * __experimentalNavigatorBackButton as NavigatorBackButton, - * } from '@wordpress/components'; - * - * const MyNavigation = () => ( - * - * - *

This is the home screen.

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

This is the child screen.

- * - * Go back (to parent) - * - *
- *
- * ); - * ``` - */ export const NavigatorBackButton = contextConnect( UnconnectedNavigatorBackButton, - 'NavigatorBackButton' + 'Navigator.BackButton' ); - -export default NavigatorBackButton; diff --git a/packages/components/src/navigator/navigator-back-button/hook.ts b/packages/components/src/navigator/navigator-back-button/hook.ts index 9ddc095292190a..d6fcd39647bff9 100644 --- a/packages/components/src/navigator/navigator-back-button/hook.ts +++ b/packages/components/src/navigator/navigator-back-button/hook.ts @@ -20,7 +20,7 @@ export function useNavigatorBackButton( as = Button, ...otherProps - } = useContextSystem( props, 'NavigatorBackButton' ); + } = useContextSystem( props, 'Navigator.BackButton' ); const { goBack } = useNavigator(); const handleClick: React.MouseEventHandler< HTMLButtonElement > = diff --git a/packages/components/src/navigator/navigator-button/README.md b/packages/components/src/navigator/navigator-button/README.md deleted file mode 100644 index 72154ec317da44..00000000000000 --- a/packages/components/src/navigator/navigator-button/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# `NavigatorButton` - -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -The `NavigatorButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components (or the `useNavigator` hook). - -## Usage - -Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example. - -## Props - -The component accepts the following props: - -### `attributeName`: `string` - -The HTML attribute used to identify the `NavigatorButton`, which is used by `Navigator` to restore focus. - -- Required: No -- Default: `id` - -### `onClick`: `React.MouseEventHandler< HTMLElement >` - -The callback called in response to a `click` event. - -- Required: No - -### `path`: `string` - -The path of the screen to navigate to. The value of this prop needs to be [a valid value for an HTML attribute](https://html.spec.whatwg.org/multipage/syntax.html#attributes-2). - -- Required: Yes - -### Inherited props - -`NavigatorButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. diff --git a/packages/components/src/navigator/navigator-button/component.tsx b/packages/components/src/navigator/navigator-button/component.tsx index 1b84a2315c04d3..a6dc7963376723 100644 --- a/packages/components/src/navigator/navigator-button/component.tsx +++ b/packages/components/src/navigator/navigator-button/component.tsx @@ -21,42 +21,7 @@ function UnconnectedNavigatorButton( return ; } -/** - * The `NavigatorButton` component can be used to navigate to a screen and should - * be used in combination with the `NavigatorProvider`, the `NavigatorScreen` - * and the `NavigatorBackButton` components (or the `useNavigator` hook). - * - * @example - * ```jsx - * import { - * __experimentalNavigatorProvider as NavigatorProvider, - * __experimentalNavigatorScreen as NavigatorScreen, - * __experimentalNavigatorButton as NavigatorButton, - * __experimentalNavigatorBackButton as NavigatorBackButton, - * } from '@wordpress/components'; - * - * const MyNavigation = () => ( - * - * - *

This is the home screen.

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

This is the child screen.

- * - * Go back - * - *
- *
- * ); - * ``` - */ export const NavigatorButton = contextConnect( UnconnectedNavigatorButton, - 'NavigatorButton' + 'Navigator.Button' ); - -export default NavigatorButton; diff --git a/packages/components/src/navigator/navigator-button/hook.ts b/packages/components/src/navigator/navigator-button/hook.ts index 3e39b05661e1b2..59d2aaa65662d7 100644 --- a/packages/components/src/navigator/navigator-button/hook.ts +++ b/packages/components/src/navigator/navigator-button/hook.ts @@ -25,7 +25,7 @@ export function useNavigatorButton( as = Button, attributeName = 'id', ...otherProps - } = useContextSystem( props, 'NavigatorButton' ); + } = useContextSystem( props, 'Navigator.Button' ); const escapedPath = escapeAttribute( path ); diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md deleted file mode 100644 index 35bf7a69720be2..00000000000000 --- a/packages/components/src/navigator/navigator-provider/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# `NavigatorProvider` - -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -The `NavigatorProvider` component allows rendering nested views/panels/menus (via the [`NavigatorScreen` component](/packages/components/src/navigator/navigator-screen/README.md)) and navigate between these different states (via the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md), [`NavigatorToParentButton`](/packages/components/src/navigator/navigator-to-parent-button/README.md) and [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components or the `useNavigator` hook). The Global Styles sidebar is an example of this. - -## Usage - -```jsx -import { - __experimentalNavigatorProvider as NavigatorProvider, - __experimentalNavigatorScreen as NavigatorScreen, - __experimentalNavigatorButton as NavigatorButton, - __experimentalNavigatorBackButton as NavigatorBackButton, -} from '@wordpress/components'; - -const MyNavigation = () => ( - - -

This is the home screen.

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

This is the child screen.

- Go back -
-
-); -``` - -**Important note** - -`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. - -`Navigator` will treat "back" navigations as going to the parent screen ā€” it is therefore responsibility of the consumer of the component to create the correct screen hierarchy. - -For example: - -- `/` is the root of all paths. There should always be a screen with `path="/"`. -- `/parent/child` is a child of `/parent`. -- `/parent/child/grand-child` is a child of `/parent/child`. -- `/parent/:param` is a child of `/parent` as well. -- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. - -## Props - -The component accepts the following props: - -### `initialPath`: `string` - -The initial active path. - -- Required: No - -## The `navigator` object - -You can retrieve a `navigator` instance by using the `useNavigator` hook. - -The `navigator` instance has a few properties: - -### `goTo`: `( path: string, options: NavigateOptions ) => void` - -The `goTo` function allows navigating to a given path. The second argument can augment the navigation operations with different options. - -The available options are: - -- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back; -- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too); -- `skipFocus`: `boolean`. An optional property used to opt out of `Navigator`'s focus management, useful when the consumer of the component wants to manage focus themselves; - -### `goBack`: `( path: string, options: NavigateOptions ) => void` - -The `goBack` function allows navigating to the parent screen. Parent/child navigation only works if the paths you define are hierarchical (see note above). - -When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) are found. - -The available options are the same as for the `goTo` method, except for the `isBack` property, which is not available for the `goBack` method. - -### `location`: `NavigatorLocation` - -The `location` object represent the current location, and has a few properties: - -- `path`: `string`. The path associated to the location. -- `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards. -- `isInitial`: `boolean`. A flag that is `true` only for the initial location. - -### `params`: `Record< string, string | string[] >` - -The parsed record of parameters from the current location. For example if the current screen path is `/product/:productId` and the location is `/product/123`, then `params` will be `{ productId: '123' }`. diff --git a/packages/components/src/navigator/navigator-screen/README.md b/packages/components/src/navigator/navigator-screen/README.md deleted file mode 100644 index 583da461cd3c27..00000000000000 --- a/packages/components/src/navigator/navigator-screen/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# `NavigatorScreen` - -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -The `NavigatorScreen` component represents a single view/screen/panel and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) and the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components (or the `useNavigator` hook). - -## Usage - -Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example. - -## Props - -The component accepts the following props: - -### `path`: `string` - -The screen"s path, matched against the current path stored in the navigator. - -`Navigator` assumes that screens are organized hierarchically according to their `path`, which should follow a URL-like scheme where each path segment starts with and is separated by the `/` character. - -`Navigator` will treat "back" navigations as going to the parent screen ā€” it is therefore responsibility of the consumer of the component to create the correct screen hierarchy. - -For example: - -- `/` is the root of all paths. There should always be a screen with `path="/"`. -- `/parent/child` is a child of `/parent`. -- `/parent/child/grand-child` is a child of `/parent/child`. -- `/parent/:param` is a child of `/parent` as well. -- if the current screen has a `path` with value `/parent/child/grand-child`, when going "back" `Navigator` will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) is found. - -- Required: Yes diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 5882f271d4518f..fe0d81b90a17be 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -15,7 +15,6 @@ import { useId, } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; -import { isRTL as isRTLFn } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; import warning from '@wordpress/warning'; @@ -29,6 +28,7 @@ import { View } from '../../view'; import { NavigatorContext } from '../context'; import * as styles from '../styles'; import type { NavigatorScreenProps } from '../types'; +import { useScreenAnimatePresence } from './use-screen-animate-presence'; function UnconnectedNavigatorScreen( props: WordPressComponentProps< NavigatorScreenProps, 'div', false >, @@ -36,21 +36,29 @@ function UnconnectedNavigatorScreen( ) { if ( ! /^\//.test( props.path ) ) { warning( - 'wp.components.NavigatorScreen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.' + 'wp.components.Navigator.Screen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.' ); } const screenId = useId(); - const { children, className, path, ...otherProps } = useContextSystem( - props, - 'NavigatorScreen' - ); + + const { + children, + className, + path, + onAnimationEnd: onAnimationEndProp, + ...otherProps + } = useContextSystem( props, 'Navigator.Screen' ); const { location, match, addScreen, removeScreen } = useContext( NavigatorContext ); + const { isInitial, isBack, focusTargetSelector, skipFocus } = location; + const isMatch = match === screenId; const wrapperRef = useRef< HTMLDivElement >( null ); + const skipAnimationAndFocusRestoration = !! isInitial && ! isBack; + // Register / unregister screen with the navigator context. useEffect( () => { const screen = { id: screenId, @@ -60,31 +68,28 @@ function UnconnectedNavigatorScreen( return () => removeScreen( screen ); }, [ screenId, path, addScreen, removeScreen ] ); - const isRTL = isRTLFn(); - const { isInitial, isBack } = location; + // Animation. + const { animationStyles, shouldRenderScreen, screenProps } = + useScreenAnimatePresence( { + isMatch, + isBack, + onAnimationEnd: onAnimationEndProp, + skipAnimation: skipAnimationAndFocusRestoration, + } ); + const cx = useCx(); const classes = useMemo( - () => - cx( - styles.navigatorScreen( { - isInitial, - isBack, - isRTL, - } ), - className - ), - [ className, cx, isInitial, isBack, isRTL ] + () => cx( styles.navigatorScreen, animationStyles, className ), + [ className, cx, animationStyles ] ); + // Focus restoration const locationRef = useRef( location ); - useEffect( () => { locationRef.current = location; }, [ location ] ); - - // Focus restoration - const isInitialLocation = location.isInitial && ! location.isBack; useEffect( () => { + const wrapperEl = wrapperRef.current; // Only attempt to restore focus: // - if the current location is not the initial one (to avoid moving focus on page load) // - when the screen becomes visible @@ -92,20 +97,20 @@ function UnconnectedNavigatorScreen( // - if focus hasn't already been restored for the current location // - if the `skipFocus` option is not set to `true`. This is useful when we trigger the navigation outside of NavigatorScreen. if ( - isInitialLocation || + skipAnimationAndFocusRestoration || ! isMatch || - ! wrapperRef.current || + ! wrapperEl || locationRef.current.hasRestoredFocus || - location.skipFocus + skipFocus ) { return; } - const activeElement = wrapperRef.current.ownerDocument.activeElement; + const activeElement = wrapperEl.ownerDocument.activeElement; // If an element is already focused within the wrapper do not focus the // element. This prevents inputs or buttons from losing focus unnecessarily. - if ( wrapperRef.current.contains( activeElement ) ) { + if ( wrapperEl.contains( activeElement ) ) { return; } @@ -113,75 +118,42 @@ function UnconnectedNavigatorScreen( // When navigating back, if a selector is provided, use it to look for the // target element (assumed to be a node inside the current NavigatorScreen) - if ( location.isBack && location.focusTargetSelector ) { - elementToFocus = wrapperRef.current.querySelector( - location.focusTargetSelector - ); + if ( isBack && focusTargetSelector ) { + elementToFocus = wrapperEl.querySelector( focusTargetSelector ); } // If the previous query didn't run or find any element to focus, fallback // to the first tabbable element in the screen (or the screen itself). if ( ! elementToFocus ) { - const [ firstTabbable ] = focus.tabbable.find( wrapperRef.current ); - elementToFocus = firstTabbable ?? wrapperRef.current; + const [ firstTabbable ] = focus.tabbable.find( wrapperEl ); + elementToFocus = firstTabbable ?? wrapperEl; } locationRef.current.hasRestoredFocus = true; elementToFocus.focus(); }, [ - isInitialLocation, + skipAnimationAndFocusRestoration, isMatch, - location.isBack, - location.focusTargetSelector, - location.skipFocus, + isBack, + focusTargetSelector, + skipFocus, ] ); const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); - return isMatch ? ( - + return shouldRenderScreen ? ( + { children } ) : null; } -/** - * The `NavigatorScreen` component represents a single view/screen/panel and - * should be used in combination with the `NavigatorProvider`, the - * `NavigatorButton` and the `NavigatorBackButton` components (or the `useNavigator` - * hook). - * - * @example - * ```jsx - * import { - * __experimentalNavigatorProvider as NavigatorProvider, - * __experimentalNavigatorScreen as NavigatorScreen, - * __experimentalNavigatorButton as NavigatorButton, - * __experimentalNavigatorBackButton as NavigatorBackButton, - * } from '@wordpress/components'; - * - * const MyNavigation = () => ( - * - * - *

This is the home screen.

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

This is the child screen.

- * - * Go back - * - *
- *
- * ); - * ``` - */ export const NavigatorScreen = contextConnect( UnconnectedNavigatorScreen, - 'NavigatorScreen' + 'Navigator.Screen' ); - -export default NavigatorScreen; diff --git a/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts new file mode 100644 index 00000000000000..af5a47ee12df4c --- /dev/null +++ b/packages/components/src/navigator/navigator-screen/use-screen-animate-presence.ts @@ -0,0 +1,177 @@ +/** + * WordPress dependencies + */ +import { + useState, + useEffect, + useLayoutEffect, + useCallback, +} from '@wordpress/element'; +import { useReducedMotion } from '@wordpress/compose'; +import { isRTL as isRTLFn } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import * as styles from '../styles'; + +// Possible values: +// - 'INITIAL': the initial state +// - 'ANIMATING_IN': start enter animation +// - 'IN': enter animation has ended +// - 'ANIMATING_OUT': start exit animation +// - 'OUT': the exit animation has ended +type AnimationStatus = + | 'INITIAL' + | 'ANIMATING_IN' + | 'IN' + | 'ANIMATING_OUT' + | 'OUT'; + +// Allow an extra 20% of the total animation duration to account for potential +// event loop delays. +const ANIMATION_TIMEOUT_MARGIN = 1.2; + +const isEnterAnimation = ( + animationDirection: 'end' | 'start', + animationStatus: AnimationStatus, + animationName: string +) => + animationStatus === 'ANIMATING_IN' && + animationName === styles.ANIMATION_END_NAMES[ animationDirection ].in; + +const isExitAnimation = ( + animationDirection: 'end' | 'start', + animationStatus: AnimationStatus, + animationName: string +) => + animationStatus === 'ANIMATING_OUT' && + animationName === styles.ANIMATION_END_NAMES[ animationDirection ].out; + +export function useScreenAnimatePresence( { + isMatch, + skipAnimation, + isBack, + onAnimationEnd, +}: { + isMatch: boolean; + skipAnimation: boolean; + isBack?: boolean; + onAnimationEnd?: React.AnimationEventHandler< Element >; +} ) { + const isRTL = isRTLFn(); + const prefersReducedMotion = useReducedMotion(); + + const [ animationStatus, setAnimationStatus ] = + useState< AnimationStatus >( 'INITIAL' ); + + // Start enter and exit animations when the screen is selected or deselected. + // The animation status is set to `IN` or `OUT` immediately if the animation + // should be skipped. + const becameSelected = + animationStatus !== 'ANIMATING_IN' && + animationStatus !== 'IN' && + isMatch; + const becameUnselected = + animationStatus !== 'ANIMATING_OUT' && + animationStatus !== 'OUT' && + ! isMatch; + useLayoutEffect( () => { + if ( becameSelected ) { + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'IN' : 'ANIMATING_IN' + ); + } else if ( becameUnselected ) { + setAnimationStatus( + skipAnimation || prefersReducedMotion ? 'OUT' : 'ANIMATING_OUT' + ); + } + }, [ + becameSelected, + becameUnselected, + skipAnimation, + prefersReducedMotion, + ] ); + + // Animation attributes (derived state). + const animationDirection = + ( isRTL && isBack ) || ( ! isRTL && ! isBack ) ? 'end' : 'start'; + const isAnimatingIn = animationStatus === 'ANIMATING_IN'; + const isAnimatingOut = animationStatus === 'ANIMATING_OUT'; + let animationType: 'in' | 'out' | undefined; + if ( isAnimatingIn ) { + animationType = 'in'; + } else if ( isAnimatingOut ) { + animationType = 'out'; + } + + const onScreenAnimationEnd = useCallback( + ( e: React.AnimationEvent< HTMLElement > ) => { + onAnimationEnd?.( e ); + + if ( + isExitAnimation( + animationDirection, + animationStatus, + e.animationName + ) + ) { + // When the exit animation ends on an unselected screen, set the + // status to 'OUT' to remove the screen contents from the DOM. + setAnimationStatus( 'OUT' ); + } else if ( + isEnterAnimation( + animationDirection, + animationStatus, + e.animationName + ) + ) { + // When the enter animation ends on a selected screen, set the + // status to 'IN' to ensure the screen is rendered in the DOM. + setAnimationStatus( 'IN' ); + } + }, + [ onAnimationEnd, animationStatus, animationDirection ] + ); + + // Fallback timeout to ensure that the logic is applied even if the + // `animationend` event is not triggered. + useEffect( () => { + let animationTimeout: number | undefined; + + if ( isAnimatingOut ) { + animationTimeout = window.setTimeout( () => { + setAnimationStatus( 'OUT' ); + animationTimeout = undefined; + }, styles.TOTAL_ANIMATION_DURATION.OUT * ANIMATION_TIMEOUT_MARGIN ); + } else if ( isAnimatingIn ) { + animationTimeout = window.setTimeout( () => { + setAnimationStatus( 'IN' ); + animationTimeout = undefined; + }, styles.TOTAL_ANIMATION_DURATION.IN * ANIMATION_TIMEOUT_MARGIN ); + } + + return () => { + if ( animationTimeout ) { + window.clearTimeout( animationTimeout ); + animationTimeout = undefined; + } + }; + }, [ isAnimatingOut, isAnimatingIn ] ); + + return { + animationStyles: styles.navigatorScreenAnimation, + // Render the screen's contents in the DOM not only when the screen is + // selected, but also while it is animating out. + shouldRenderScreen: + isMatch || + animationStatus === 'IN' || + animationStatus === 'ANIMATING_OUT', + screenProps: { + onAnimationEnd: onScreenAnimationEnd, + 'data-animation-direction': animationDirection, + 'data-animation-type': animationType, + 'data-skip-animation': skipAnimation || undefined, + }, + } as const; +} diff --git a/packages/components/src/navigator/navigator-to-parent-button/README.md b/packages/components/src/navigator/navigator-to-parent-button/README.md deleted file mode 100644 index 0100ea9b8d2e1f..00000000000000 --- a/packages/components/src/navigator/navigator-to-parent-button/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# `NavigatorToParentButton` - -
-This feature is still experimental. ā€œExperimentalā€ means this is an early implementation subject to drastic and breaking changes. -
- -This component is deprecated. Please use the [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) component instead. - -The `NavigatorToParentButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook). - -## Usage - -Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example. - -### Inherited props - -`NavigatorToParentButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`. diff --git a/packages/components/src/navigator/navigator-to-parent-button/component.tsx b/packages/components/src/navigator/navigator-to-parent-button/component.tsx index fcbadea03cf7bb..f1c2d27e2284a1 100644 --- a/packages/components/src/navigator/navigator-to-parent-button/component.tsx +++ b/packages/components/src/navigator/navigator-to-parent-button/component.tsx @@ -17,21 +17,16 @@ function UnconnectedNavigatorToParentButton( ) { deprecated( 'wp.components.NavigatorToParentButton', { since: '6.7', - alternative: 'wp.components.NavigatorBackButton', + alternative: 'wp.components.Navigator.BackButton', } ); return ; } /** - * _Note: this component is deprecated. Please use the `NavigatorBackButton` - * component instead._ - * * @deprecated */ export const NavigatorToParentButton = contextConnect( UnconnectedNavigatorToParentButton, - 'NavigatorToParentButton' + 'Navigator.ToParentButton' ); - -export default NavigatorToParentButton; diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator/component.tsx similarity index 81% rename from packages/components/src/navigator/navigator-provider/component.tsx rename to packages/components/src/navigator/navigator/component.tsx index ebcb247c574830..bd49b3682fb144 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator/component.tsx @@ -21,7 +21,7 @@ import { View } from '../../view'; import { NavigatorContext } from '../context'; import * as styles from '../styles'; import type { - NavigatorProviderProps, + NavigatorProps, NavigatorLocation, NavigatorContext as NavigatorContextType, NavigateOptions, @@ -66,7 +66,7 @@ function goTo( options: NavigateOptions = {} ) { const { focusSelectors } = state; - const currentLocation = { ...state.currentLocation, isInitial: false }; + const currentLocation = { ...state.currentLocation }; const { // Default assignments @@ -114,6 +114,7 @@ function goTo( return { currentLocation: { ...restOptions, + isInitial: false, path, isBack, hasRestoredFocus: false, @@ -129,7 +130,7 @@ function goToParent( options: NavigateToParentOptions = {} ) { const { screens, focusSelectors } = state; - const currentLocation = { ...state.currentLocation, isInitial: false }; + const currentLocation = { ...state.currentLocation }; const currentPath = currentLocation.path; if ( currentPath === undefined ) { return { currentLocation, focusSelectors }; @@ -212,8 +213,8 @@ function routerReducer( }; } -function UnconnectedNavigatorProvider( - props: WordPressComponentProps< NavigatorProviderProps, 'div' >, +function UnconnectedNavigator( + props: WordPressComponentProps< NavigatorProps, 'div' >, forwardedRef: ForwardedRef< any > ) { const { @@ -221,7 +222,7 @@ function UnconnectedNavigatorProvider( children, className, ...otherProps - } = useContextSystem( props, 'NavigatorProvider' ); + } = useContextSystem( props, 'Navigator' ); const [ routerState, dispatch ] = useReducer( routerReducer, @@ -274,7 +275,7 @@ function UnconnectedNavigatorProvider( const cx = useCx(); const classes = useMemo( - () => cx( styles.navigatorProviderWrapper, className ), + () => cx( styles.navigatorWrapper, className ), [ className, cx ] ); @@ -287,42 +288,4 @@ function UnconnectedNavigatorProvider( ); } -/** - * The `NavigatorProvider` component allows rendering nested views/panels/menus - * (via the `NavigatorScreen` component and navigate between these different - * view (via the `NavigatorButton` and `NavigatorBackButton` components or the - * `useNavigator` hook). - * - * ```jsx - * import { - * __experimentalNavigatorProvider as NavigatorProvider, - * __experimentalNavigatorScreen as NavigatorScreen, - * __experimentalNavigatorButton as NavigatorButton, - * __experimentalNavigatorBackButton as NavigatorBackButton, - * } from '@wordpress/components'; - * - * const MyNavigation = () => ( - * - * - *

This is the home screen.

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

This is the child screen.

- * - * Go back - * - *
- *
- * ); - * ``` - */ -export const NavigatorProvider = contextConnect( - UnconnectedNavigatorProvider, - 'NavigatorProvider' -); - -export default NavigatorProvider; +export const Navigator = contextConnect( UnconnectedNavigator, 'Navigator' ); diff --git a/packages/components/src/navigator/stories/index.story.tsx b/packages/components/src/navigator/stories/index.story.tsx index 30b9c71a368c1a..e9e342bb0d2eee 100644 --- a/packages/components/src/navigator/stories/index.story.tsx +++ b/packages/components/src/navigator/stories/index.story.tsx @@ -8,20 +8,20 @@ import type { Meta, StoryObj } from '@storybook/react'; */ import Button from '../../button'; import { VStack } from '../../v-stack'; -import { - NavigatorProvider, - NavigatorScreen, - NavigatorButton, - NavigatorBackButton, - useNavigator, -} from '..'; import { HStack } from '../../h-stack'; - -const meta: Meta< typeof NavigatorProvider > = { - component: NavigatorProvider, - // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 - subcomponents: { NavigatorScreen, NavigatorButton, NavigatorBackButton }, - title: 'Components (Experimental)/Navigator', +import { Navigator, useNavigator } from '../'; + +const meta: Meta< typeof Navigator > = { + component: Navigator, + subcomponents: { + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Screen: Navigator.Screen, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + Button: Navigator.Button, + // @ts-expect-error - See https://github.com/storybookjs/storybook/issues/23170 + BackButton: Navigator.BackButton, + }, + title: 'Components/Navigator', argTypes: { as: { control: { type: null } }, children: { control: { type: null } }, @@ -36,14 +36,14 @@ const meta: Meta< typeof NavigatorProvider > = { return ( <> @@ -55,55 +55,55 @@ const meta: Meta< typeof NavigatorProvider > = { }; export default meta; -export const Default: StoryObj< typeof NavigatorProvider > = { +export const Default: StoryObj< typeof Navigator > = { args: { initialPath: '/', children: ( <> - +

This is the home screen.

- + Go to child screen. - + - + Go to dynamic path screen with id 1. - + - + Go to dynamic path screen with id 2. - + -
+ - +

This is the child screen.

- + Go back - + - Go to grand child screen. - + -
+ - +

This is the grand child screen.

- + Go back - -
+ + - + - + ), }, @@ -119,14 +119,14 @@ function DynamicScreen() { This screen can parse params dynamically. The current id is:{ ' ' } { params.id }

- + Go back - + ); } -export const WithNestedInitialPath: StoryObj< typeof NavigatorProvider > = { +export const WithNestedInitialPath: StoryObj< typeof Navigator > = { ...Default, args: { ...Default.args, @@ -138,7 +138,7 @@ const NavigatorButtonWithSkipFocus = ( { path, onClick, ...props -}: React.ComponentProps< typeof NavigatorButton > ) => { +}: React.ComponentProps< typeof Navigator.Button > ) => { const { goTo } = useNavigator(); return ( @@ -156,7 +156,7 @@ const NavigatorButtonWithSkipFocus = ( { ); }; -export const SkipFocus: StoryObj< typeof NavigatorProvider > = { +export const SkipFocus: StoryObj< typeof Navigator > = { args: { initialPath: '/', children: ( @@ -167,21 +167,22 @@ export const SkipFocus: StoryObj< typeof NavigatorProvider > = { outline: '1px solid black', outlineOffset: '-1px', marginBlockEnd: '1rem', + display: 'contents', } } > - +

Home screen

- + Go to child screen. - -
+ + - +

Child screen

- + Go back to home screen - -
+ +
diff --git a/packages/components/src/navigator/styles.ts b/packages/components/src/navigator/styles.ts index 0203edbdf1816a..167d4ac07de3d6 100644 --- a/packages/components/src/navigator/styles.ts +++ b/packages/components/src/navigator/styles.ts @@ -3,69 +3,140 @@ */ import { css, keyframes } from '@emotion/react'; -export const navigatorProviderWrapper = css` +export const navigatorWrapper = css` + position: relative; /* Prevents horizontal overflow while animating screen transitions */ - overflow-x: hidden; - /* Mark this subsection of the DOM as isolated, providing performance benefits - * by limiting calculations of layout, style and paint to a DOM subtree rather - * than the entire page. + overflow-x: clip; + /* + * Mark this DOM subtree as isolated when it comes to layout calculations, + * providing performance benefits. */ - contain: content; + contain: layout; + + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + align-items: start; `; -const fadeInFromRight = keyframes( { - '0%': { +const fadeIn = keyframes( { + from: { opacity: 0, - transform: `translateX( 50px )`, }, - '100%': { opacity: 1, transform: 'none' }, } ); -const fadeInFromLeft = keyframes( { - '0%': { +const fadeOut = keyframes( { + to: { opacity: 0, - transform: `translateX( -50px )`, }, - '100%': { opacity: 1, transform: 'none' }, } ); -type NavigatorScreenAnimationProps = { - isInitial?: boolean; - isBack?: boolean; - isRTL: boolean; +export const slideFromRight = keyframes( { + from: { + transform: 'translateX(100px)', + }, +} ); + +export const slideToLeft = keyframes( { + to: { + transform: 'translateX(-80px)', + }, +} ); + +export const slideFromLeft = keyframes( { + from: { + transform: 'translateX(-100px)', + }, +} ); + +export const slideToRight = keyframes( { + to: { + transform: 'translateX(80px)', + }, +} ); + +const FADE = { + DURATION: 70, + EASING: 'linear', + DELAY: { + IN: 70, + OUT: 40, + }, +}; +const SLIDE = { + DURATION: 300, + EASING: 'cubic-bezier(0.33, 0, 0, 1)', +}; + +export const TOTAL_ANIMATION_DURATION = { + IN: Math.max( FADE.DURATION + FADE.DELAY.IN, SLIDE.DURATION ), + OUT: Math.max( FADE.DURATION + FADE.DELAY.OUT, SLIDE.DURATION ), }; -const navigatorScreenAnimation = ( { - isInitial, - isBack, - isRTL, -}: NavigatorScreenAnimationProps ) => { - if ( isInitial && ! isBack ) { - return; - } +export const ANIMATION_END_NAMES = { + end: { + in: slideFromRight.name, + out: slideToLeft.name, + }, + start: { + in: slideFromLeft.name, + out: slideToRight.name, + }, +}; - const animationName = - ( isRTL && isBack ) || ( ! isRTL && ! isBack ) - ? fadeInFromRight - : fadeInFromLeft; +const ANIMATION = { + end: { + in: css` + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromRight } + `, + out: css` + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .OUT }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToLeft } + `, + }, + start: { + in: css` + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .IN }ms both ${ fadeIn }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideFromLeft } + `, + out: css` + ${ FADE.DURATION }ms ${ FADE.EASING } ${ FADE.DELAY + .OUT }ms both ${ fadeOut }, ${ SLIDE.DURATION }ms ${ SLIDE.EASING } both ${ slideToRight } + `, + }, +} as const; +export const navigatorScreenAnimation = css` + z-index: 1; - return css` - animation-duration: 0.14s; - animation-timing-function: ease-in-out; - will-change: transform, opacity; - animation-name: ${ animationName }; + &[data-animation-type='out'] { + z-index: 0; + } - @media ( prefers-reduced-motion ) { - animation-duration: 0s; + @media not ( prefers-reduced-motion ) { + &:not( [data-skip-animation] ) { + ${ ( [ 'start', 'end' ] as const ).map( ( direction ) => + ( [ 'in', 'out' ] as const ).map( + ( type ) => css` + &[data-animation-direction='${ direction }'][data-animation-type='${ type }'] { + animation: ${ ANIMATION[ direction ][ type ] }; + } + ` + ) + ) } } - `; -}; + } +`; -export const navigatorScreen = ( props: NavigatorScreenAnimationProps ) => css` +export const navigatorScreen = css` /* Ensures horizontal overflow is visually accessible */ overflow-x: auto; /* In case the root has a height, it should not be exceeded */ max-height: 100%; + box-sizing: border-box; + + position: relative; - ${ navigatorScreenAnimation( props ) } + grid-column: 1 / -1; + grid-row: 1 / -1; `; diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx index 820942a22644ba..cab6e9a4cdadff 100644 --- a/packages/components/src/navigator/test/index.tsx +++ b/packages/components/src/navigator/test/index.tsx @@ -14,14 +14,8 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import Button from '../../button'; -import { - NavigatorProvider, - NavigatorScreen, - NavigatorButton, - NavigatorBackButton, - NavigatorToParentButton, - useNavigator, -} from '..'; +import { Navigator, useNavigator } from '..'; +import { NavigatorToParentButton } from '../legacy'; import type { NavigateOptions } from '../types'; const INVALID_HTML_ATTRIBUTE = { @@ -76,11 +70,11 @@ function CustomNavigatorButton( { path, onClick, ...props -}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { +}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & { onClick?: CustomTestOnClickHandler; } ) { return ( - { // Used to spy on the values passed to `navigator.goTo`. onClick?.( { type: 'goTo', path } ); @@ -95,7 +89,7 @@ function CustomNavigatorGoToBackButton( { path, onClick, ...props -}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { +}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & { onClick?: CustomTestOnClickHandler; } ) { const { goTo } = useNavigator(); @@ -115,7 +109,7 @@ function CustomNavigatorGoToSkipFocusButton( { path, onClick, ...props -}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { +}: Omit< ComponentPropsWithoutRef< typeof Navigator.Button >, 'onClick' > & { onClick?: CustomTestOnClickHandler; } ) { const { goTo } = useNavigator(); @@ -134,11 +128,14 @@ function CustomNavigatorGoToSkipFocusButton( { function CustomNavigatorBackButton( { onClick, ...props -}: Omit< ComponentPropsWithoutRef< typeof NavigatorBackButton >, 'onClick' > & { +}: Omit< + ComponentPropsWithoutRef< typeof Navigator.BackButton >, + 'onClick' +> & { onClick?: CustomTestOnClickHandler; } ) { return ( - { // Used to spy on the values passed to `navigator.goBack`. onClick?.( { type: 'goBack' } ); @@ -151,7 +148,10 @@ function CustomNavigatorBackButton( { function CustomNavigatorToParentButton( { onClick, ...props -}: Omit< ComponentPropsWithoutRef< typeof NavigatorBackButton >, 'onClick' > & { +}: Omit< + ComponentPropsWithoutRef< typeof Navigator.BackButton >, + 'onClick' +> & { onClick?: CustomTestOnClickHandler; } ) { return ( @@ -194,13 +194,13 @@ const ProductScreen = ( { const { params } = useNavigator(); return ( - +

{ SCREEN_TEXT.product }

Product ID is { params.productId }

{ BUTTON_TEXT.back } -
+ ); }; @@ -215,8 +215,8 @@ const MyNavigation = ( { const [ outerInputValue, setOuterInputValue ] = useState( '' ); return ( <> - - + +

{ SCREEN_TEXT.home }

{ /* * A button useful to test focus restoration. This button is the first @@ -254,9 +254,9 @@ const MyNavigation = ( { > { BUTTON_TEXT.toInvalidHtmlPathScreen } -
+ - +

{ SCREEN_TEXT.child }

{ /* * A button useful to test focus restoration. This button is the first @@ -286,30 +286,30 @@ const MyNavigation = ( { } } value={ innerInputValue } /> -
+ - +

{ SCREEN_TEXT.nested }

{ BUTTON_TEXT.back } -
+ - +

{ SCREEN_TEXT.invalidHtmlPath }

{ BUTTON_TEXT.back } -
+ - { /* A `NavigatorScreen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ } -
+ { /* A `Navigator.Screen` with `path={ PATHS.NOT_FOUND }` is purposefully not included. */ } + { return ( <> - - + +

{ SCREEN_TEXT.home }

{ /* * A button useful to test focus restoration. This button is the first @@ -349,9 +349,9 @@ const MyHierarchicalNavigation = ( { > { BUTTON_TEXT.toChildScreen } -
+ - +

{ SCREEN_TEXT.child }

{ /* * A button useful to test focus restoration. This button is the first @@ -370,9 +370,9 @@ const MyHierarchicalNavigation = ( { > { BUTTON_TEXT.back } -
+ - +

{ SCREEN_TEXT.nested }

{ BUTTON_TEXT.backUsingGoTo } -
+ { BUTTON_TEXT.goToWithSkipFocus } -
+ ); }; @@ -406,8 +406,8 @@ const MyDeprecatedNavigation = ( { } ) => { return ( <> - - + +

{ SCREEN_TEXT.home }

{ /* * A button useful to test focus restoration. This button is the first @@ -421,9 +421,9 @@ const MyDeprecatedNavigation = ( { > { BUTTON_TEXT.toChildScreen } -
+ - +

{ SCREEN_TEXT.child }

{ /* * A button useful to test focus restoration. This button is the first @@ -442,17 +442,17 @@ const MyDeprecatedNavigation = ( { > { BUTTON_TEXT.back } -
+ - +

{ SCREEN_TEXT.nested }

{ BUTTON_TEXT.back } -
-
+ + ); }; @@ -643,10 +643,10 @@ describe( 'Navigator', () => { } ); it( 'should warn if the `path` prop does not follow the required format', () => { - render( Test ); + render( Test ); expect( console ).toHaveWarnedWith( - 'wp.components.NavigatorScreen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.' + 'wp.components.Navigator.Screen: the `path` should follow a URL-like scheme; it should start with and be separated by the `/` character.' ); } ); @@ -880,7 +880,7 @@ describe( 'Navigator', () => { // Rendering `NavigatorToParentButton` logs a deprecation notice expect( console ).toHaveWarnedWith( - 'wp.components.NavigatorToParentButton is deprecated since version 6.7. Please use wp.components.NavigatorBackButton instead.' + 'wp.components.NavigatorToParentButton is deprecated since version 6.7. Please use wp.components.Navigator.BackButton instead.' ); } ); diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index 855787b4d0a193..aeb5fd3b12c7fb 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -86,7 +86,7 @@ export type NavigatorContext = Navigator & { match?: string; }; -export type NavigatorProviderProps = { +export type NavigatorProps = { /** * The initial active path. */ @@ -100,6 +100,24 @@ export type NavigatorProviderProps = { export type NavigatorScreenProps = { /** * The screen's path, matched against the current path stored in the navigator. + * + * `Navigator` assumes that screens are organized hierarchically according + * to their `path`, which should follow a URL-like scheme where each path + * segment starts with and is separated by the `/` character. + * + * `Navigator` will treat "back" navigations as going to the parent screen ā€” + * it is, therefore, the responsibility of the consumer of the component to + * create the correct screen hierarchy. + * + * For example: + * - `/` is the root of all paths. There should always be a screen with + * `path="/"`; + * - `/parent/child` is a child of `/parent`; + * - `/parent/child/grand-child` is a child of `/parent/child`; + * - `/parent/:param` is a child of `/parent` as well; + * - if the current screen has a `path="/parent/child/grand-child"`, when + * going "back" `Navigator` will try to recursively navigate the path + * hierarchy until a matching screen (or the root `/`) is found. */ path: string; /** diff --git a/packages/components/src/navigator/use-navigator.ts b/packages/components/src/navigator/use-navigator.ts index 7ac35d73150d32..1ea99f3f1c857d 100644 --- a/packages/components/src/navigator/use-navigator.ts +++ b/packages/components/src/navigator/use-navigator.ts @@ -10,7 +10,10 @@ import { NavigatorContext } from './context'; import type { Navigator } from './types'; /** - * Retrieves a `navigator` instance. + * Retrieves a `navigator` instance. This hook provides advanced functionality, + * such as imperatively navigating to a new location (with options like + * navigating back or skipping focus restoration) and accessing the current + * location and path parameters. */ export function useNavigator(): Navigator { const { location, params, goTo, goBack, goToParent } = diff --git a/packages/components/src/search-control/index.tsx b/packages/components/src/search-control/index.tsx index aac905e137e025..c41eda9b209b6c 100644 --- a/packages/components/src/search-control/index.tsx +++ b/packages/components/src/search-control/index.tsx @@ -67,7 +67,7 @@ function UnforwardedSearchControl( ) { // @ts-expect-error The `disabled` prop is not yet supported in the SearchControl component. // Work with the design team (@WordPress/gutenberg-design) if you need this feature. - delete restProps.disabled; + const { disabled, ...filteredRestProps } = restProps; const searchRef = useRef< HTMLInputElement >( null ); const instanceId = useInstanceId( @@ -117,7 +117,7 @@ function UnforwardedSearchControl( /> } - { ...restProps } + { ...filteredRestProps } /> ); diff --git a/packages/components/src/select-control/stories/index.story.tsx b/packages/components/src/select-control/stories/index.story.tsx index 018f519e6b6d43..5e57a4eaecd5ab 100644 --- a/packages/components/src/select-control/stories/index.story.tsx +++ b/packages/components/src/select-control/stories/index.story.tsx @@ -12,6 +12,7 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import SelectControl from '../'; +import { InputControlPrefixWrapper } from '../../input-control/input-prefix-wrapper'; const meta: Meta< typeof SelectControl > = { title: 'Components/SelectControl', @@ -64,6 +65,7 @@ const SelectControlWithState: StoryFn< typeof SelectControl > = ( props ) => { export const Default = SelectControlWithState.bind( {} ); Default.args = { __nextHasNoMarginBottom: true, + label: 'Label', options: [ { value: '', label: 'Select an Option', disabled: true }, { value: 'a', label: 'Option A' }, @@ -76,7 +78,6 @@ export const WithLabelAndHelpText = SelectControlWithState.bind( {} ); WithLabelAndHelpText.args = { ...Default.args, help: 'Help text to explain the select control.', - label: 'Value', }; /** @@ -86,6 +87,7 @@ WithLabelAndHelpText.args = { export const WithCustomChildren = SelectControlWithState.bind( {} ); WithCustomChildren.args = { __nextHasNoMarginBottom: true, + label: 'Label', children: ( <> @@ -104,8 +106,19 @@ WithCustomChildren.args = { ), }; +/** + * 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 `` component. + */ +export const WithPrefix = SelectControlWithState.bind( {} ); +WithPrefix.args = { + ...Default.args, + prefix: Prefix:, +}; + export const Minimal = SelectControlWithState.bind( {} ); Minimal.args = { ...Default.args, variant: 'minimal', + hideLabelFromVision: true, }; diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index e5f113d93b7d0e..0f7e0d2c6ac75f 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -70,6 +70,112 @@ const Template: StoryFn< typeof Tabs > = ( props ) => { export const Default = Template.bind( {} ); +export const SizeAndOverflowPlayground: StoryFn< typeof Tabs > = ( props ) => { + const [ fullWidth, setFullWidth ] = useState( false ); + return ( +
+
+

+ This story helps understand how the TabList component + behaves under different conditions. The container below + (with the dotted red border) can be horizontally resized, + and it has a bit of padding to be out of the way of the + TabList. +

+

+ The button will toggle between full width (adding{ ' ' } + width: 100%) and the default width. +

+

Try the following:

+
    +
  • + Small container that causes tabs to + overflow with scroll. +
  • +
  • + Large container that exceeds the normal + width of the tabs. +
      +
    • + + With width: 100% + { ' ' } + set on the TabList (tabs fill up the space). +
    • +
    • + + Without width: 100% + { ' ' } + (defaults to auto) set on the + TabList (tabs take up space proportional to + their content). +
    • +
    +
  • +
+
+ + +
+ + + Label with multiple words + + Short + + Hippopotomonstrosesquippedaliophobia + + Tab 4 + Tab 5 + +
+ +

Selected tab: Tab 1

+

(Label with multiple words)

+
+ +

Selected tab: Tab 2

+

(Short)

+
+ +

Selected tab: Tab 3

+

(Hippopotomonstrosesquippedaliophobia)

+
+ +

Selected tab: Tab 4

+
+ +

Selected tab: Tab 5

+
+
+
+ ); +}; +SizeAndOverflowPlayground.args = { + defaultTabId: 'tab4', +}; + const VerticalTemplate: StoryFn< typeof Tabs > = ( props ) => { return ( diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index c00943b180f637..283d6421f5b768 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -16,32 +16,40 @@ export const TabListWrapper = styled.div` align-items: stretch; flex-direction: row; text-align: center; + overflow-x: auto; &[aria-orientation='vertical'] { flex-direction: column; text-align: start; } - @media not ( prefers-reduced-motion ) { - &.is-animation-enabled::after { - transition-property: transform; - transition-duration: 0.2s; - transition-timing-function: ease-out; - } + :where( [aria-orientation='horizontal'] ) { + width: fit-content; } + --direction-factor: 1; - --direction-origin-x: left; + --direction-start: left; + --direction-end: right; --indicator-start: var( --indicator-left ); &:dir( rtl ) { --direction-factor: -1; - --direction-origin-x: right; + --direction-start: right; + --direction-end: left; --indicator-start: var( --indicator-right ); } - &::after { + + @media not ( prefers-reduced-motion ) { + &.is-animation-enabled::before { + transition-property: transform; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + } + &::before { content: ''; position: absolute; pointer-events: none; - transform-origin: var( --direction-origin-x ) top; + transform-origin: var( --direction-start ) top; // Windows high contrast mode. outline: 2px solid transparent; @@ -52,7 +60,31 @@ export const TabListWrapper = styled.div` when scaling in the transform, see: https://stackoverflow.com/a/52159123 */ --antialiasing-factor: 100; &:not( [aria-orientation='vertical'] ) { - &::after { + --fade-width: 4rem; + --fade-gradient-base: transparent 0%, black var( --fade-width ); + --fade-gradient-composed: var( --fade-gradient-base ), black 60%, + transparent 50%; + &.is-overflowing-first { + mask-image: linear-gradient( + to var( --direction-end ), + var( --fade-gradient-base ) + ); + } + &.is-overflowing-last { + mask-image: linear-gradient( + to var( --direction-start ), + var( --fade-gradient-base ) + ); + } + &.is-overflowing-first.is-overflowing-last { + mask-image: linear-gradient( + to right, + var( --fade-gradient-composed ) + ), + linear-gradient( to left, var( --fade-gradient-composed ) ); + } + + &::before { bottom: 0; height: 0; width: calc( var( --antialiasing-factor ) * 1px ); @@ -71,8 +103,7 @@ export const TabListWrapper = styled.div` ${ COLORS.theme.accent }; } } - &[aria-orientation='vertical']::after { - z-index: -1; + &[aria-orientation='vertical']::before { top: 0; left: 0; width: 100%; @@ -87,14 +118,14 @@ export const TabListWrapper = styled.div` export const Tab = styled( Ariakit.Tab )` & { + scroll-margin: 24px; + flex-grow: 1; + flex-shrink: 0; display: inline-flex; align-items: center; position: relative; border-radius: 0; - min-height: ${ space( - 12 - ) }; // Avoid fixed height to allow for long strings that go in multiple lines. - height: auto; + height: ${ space( 12 ) }; background: transparent; border: none; box-shadow: none; @@ -104,7 +135,6 @@ export const Tab = styled( Ariakit.Tab )` margin-left: 0; font-weight: 500; text-align: inherit; - hyphens: auto; color: ${ COLORS.theme.foreground }; &[aria-disabled='true'] { @@ -123,7 +153,7 @@ export const Tab = styled( Ariakit.Tab )` } // Focus. - &::before { + &::after { content: ''; position: absolute; top: ${ space( 3 ) }; @@ -146,7 +176,7 @@ export const Tab = styled( Ariakit.Tab )` } } - &:focus-visible::before { + &:focus-visible::after { opacity: 1; } } @@ -156,6 +186,10 @@ export const Tab = styled( Ariakit.Tab )` 10 ) }; // Avoid fixed height to allow for long strings that go in multiple lines. } + + [aria-orientation='horizontal'] & { + justify-content: center; + } `; export const TabPanel = styled( Ariakit.TabPanel )` diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx index 2977d6a6283708..ae8daf60fc237c 100644 --- a/packages/components/src/tabs/tablist.tsx +++ b/packages/components/src/tabs/tablist.tsx @@ -8,7 +8,8 @@ import { useStoreState } from '@ariakit/react'; * WordPress dependencies */ import warning from '@wordpress/warning'; -import { forwardRef, useState } from '@wordpress/element'; +import { forwardRef, useLayoutEffect, useState } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; /** * Internal dependencies @@ -20,33 +21,58 @@ import type { WordPressComponentProps } from '../context'; import clsx from 'clsx'; import { useTrackElementOffsetRect } from '../utils/element-rect'; import { useOnValueUpdate } from '../utils/hooks/use-on-value-update'; +import { useTrackOverflow } from './use-track-overflow'; + +const SCROLL_MARGIN = 24; export const TabList = forwardRef< HTMLDivElement, WordPressComponentProps< TabListProps, 'div', false > >( function TabList( { children, ...otherProps }, ref ) { - const context = useTabsContext(); + const { store } = useTabsContext() ?? {}; + + const selectedId = useStoreState( store, 'selectedId' ); + const activeId = useStoreState( store, 'activeId' ); + const selectOnMove = useStoreState( store, 'selectOnMove' ); + const items = useStoreState( store, 'items' ); + const [ parent, setParent ] = useState< HTMLElement | null >(); + const refs = useMergeRefs( [ ref, setParent ] ); + const overflow = useTrackOverflow( parent, { + first: items?.at( 0 )?.element, + last: items?.at( -1 )?.element, + } ); - const tabStoreState = useStoreState( context?.store ); - const selectedId = tabStoreState?.selectedId; - const indicatorPosition = useTrackElementOffsetRect( - context?.store.item( selectedId )?.element + const selectedTabPosition = useTrackElementOffsetRect( + store?.item( selectedId )?.element ); const [ animationEnabled, setAnimationEnabled ] = useState( false ); - useOnValueUpdate( - selectedId, - ( { previousValue } ) => previousValue && setAnimationEnabled( true ) - ); + useOnValueUpdate( selectedId, ( { previousValue } ) => { + if ( previousValue ) { + setAnimationEnabled( true ); + } + } ); - if ( ! context || ! tabStoreState ) { - warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); - return null; - } + // Make sure selected tab is scrolled into view. + useLayoutEffect( () => { + if ( ! parent || ! selectedTabPosition ) { + return; + } + + const { scrollLeft: parentScroll } = parent; + const parentWidth = parent.getBoundingClientRect().width; + const { left: childLeft, width: childWidth } = selectedTabPosition; - const { store } = context; - const { activeId, selectOnMove } = tabStoreState; - const { setActiveId } = store; + const parentRightEdge = parentScroll + parentWidth; + const childRightEdge = childLeft + childWidth; + const rightOverflow = childRightEdge + SCROLL_MARGIN - parentRightEdge; + const leftOverflow = parentScroll - ( childLeft - SCROLL_MARGIN ); + if ( leftOverflow > 0 ) { + parent.scrollLeft = parentScroll - leftOverflow; + } else if ( rightOverflow > 0 ) { + parent.scrollLeft = parentScroll + rightOverflow; + } + }, [ parent, selectedTabPosition ] ); const onBlur = () => { if ( ! selectOnMove ) { @@ -58,35 +84,43 @@ export const TabList = forwardRef< // that the selected tab will receive keyboard focus when tabbing back into // the tablist. if ( selectedId !== activeId ) { - setActiveId( selectedId ); + store?.setActiveId( selectedId ); } }; + if ( ! store ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + return ( { - if ( event.pseudoElement === '::after' ) { + if ( event.pseudoElement === '::before' ) { setAnimationEnabled( false ); } } } /> } onBlur={ onBlur } + tabIndex={ -1 } { ...otherProps } style={ { - '--indicator-top': indicatorPosition.top, - '--indicator-right': indicatorPosition.right, - '--indicator-left': indicatorPosition.left, - '--indicator-width': indicatorPosition.width, - '--indicator-height': indicatorPosition.height, + '--indicator-top': selectedTabPosition.top, + '--indicator-right': selectedTabPosition.right, + '--indicator-left': selectedTabPosition.left, + '--indicator-width': selectedTabPosition.width, + '--indicator-height': selectedTabPosition.height, ...otherProps.style, } } className={ clsx( - animationEnabled ? 'is-animation-enabled' : '', + overflow.first && 'is-overflowing-first', + overflow.last && 'is-overflowing-last', + animationEnabled && 'is-animation-enabled', otherProps.className ) } > diff --git a/packages/components/src/tabs/use-track-overflow.ts b/packages/components/src/tabs/use-track-overflow.ts new file mode 100644 index 00000000000000..5f6504e6875212 --- /dev/null +++ b/packages/components/src/tabs/use-track-overflow.ts @@ -0,0 +1,76 @@ +/* eslint-disable jsdoc/require-param */ +/** + * WordPress dependencies + */ +import { useState, useEffect } from '@wordpress/element'; +import { useEvent } from '@wordpress/compose'; + +/** + * Tracks if an element contains overflow and on which end by tracking the + * first and last child elements with an `IntersectionObserver` in relation + * to the parent element. + * + * Note that the returned value will only indicate whether the first or last + * element is currently "going out of bounds" but not whether it happens on + * the X or Y axis. + */ +export function useTrackOverflow( + parent: HTMLElement | undefined | null, + children: { + first: HTMLElement | undefined | null; + last: HTMLElement | undefined | null; + } +) { + const [ first, setFirst ] = useState( false ); + const [ last, setLast ] = useState( false ); + const [ observer, setObserver ] = useState< IntersectionObserver >(); + + const callback: IntersectionObserverCallback = useEvent( ( entries ) => { + for ( const entry of entries ) { + if ( entry.target === children.first ) { + setFirst( ! entry.isIntersecting ); + } + if ( entry.target === children.last ) { + setLast( ! entry.isIntersecting ); + } + } + } ); + + useEffect( () => { + if ( ! parent || ! window.IntersectionObserver ) { + return; + } + const newObserver = new IntersectionObserver( callback, { + root: parent, + threshold: 0.9, + } ); + setObserver( newObserver ); + + return () => newObserver.disconnect(); + }, [ callback, parent ] ); + + useEffect( () => { + if ( ! observer ) { + return; + } + + if ( children.first ) { + observer.observe( children.first ); + } + if ( children.last ) { + observer.observe( children.last ); + } + + return () => { + if ( children.first ) { + observer.unobserve( children.first ); + } + if ( children.last ) { + observer.unobserve( children.last ); + } + }; + }, [ children.first, children.last, observer ] ); + + return { first, last }; +} +/* eslint-enable jsdoc/require-param */ diff --git a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap index e9b4f4ca22ab85..d2d98eaba85e6f 100644 --- a/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/toggle-group-control/test/__snapshots__/index.tsx.snap @@ -60,6 +60,55 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = outline-offset: -2px; } +@media not ( prefers-reduced-motion ) { + .emotion-8[data-indicator-animated]::before { + transition-property: transform,border-radius; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } +} + +.emotion-8::before { + content: ''; + position: absolute; + pointer-events: none; + background: #1e1e1e; + outline: 2px solid transparent; + outline-offset: -3px; + --antialiasing-factor: 100; + border-radius: calc( + 1px / + ( + var( --selected-width, 0 ) / + var( --antialiasing-factor ) + ) + )/1px; + left: -1px; + width: calc( var( --antialiasing-factor ) * 1px ); + height: calc( var( --selected-height, 0 ) * 1px ); + transform-origin: left top; + -webkit-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + -moz-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + -ms-transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); +} + .emotion-10 { display: -webkit-inline-box; display: -webkit-inline-flex; @@ -150,17 +199,7 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = line-height: 1; } -.emotion-15 { - background: #1e1e1e; - border-radius: 1px; - position: absolute; - inset: 0; - z-index: 1; - outline: 2px solid transparent; - outline-offset: -3px; -} - -.emotion-18 { +.emotion-17 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -204,22 +243,22 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = } @media not ( prefers-reduced-motion ) { - .emotion-18 { + .emotion-17 { -webkit-transition: background 160ms linear,color 160ms linear,font-weight 60ms linear; transition: background 160ms linear,color 160ms linear,font-weight 60ms linear; } } -.emotion-18::-moz-focus-inner { +.emotion-17::-moz-focus-inner { border: 0; } -.emotion-18[disabled] { +.emotion-17[disabled] { opacity: 0.4; cursor: default; } -.emotion-18:active { +.emotion-17:active { background: #fff; } @@ -280,12 +319,6 @@ exports[`ToggleGroupControl controlled should render correctly with icons 1`] = -
-
-
-
{ if ( showTooltip && text ) { return ( @@ -58,7 +51,6 @@ function ToggleGroupControlOptionBase( >, forwardedRef: ForwardedRef< any > ) { - const shouldReduceMotion = useReducedMotion(); const toggleGroupControlContext = useToggleGroupControlContext(); const id = useInstanceId( @@ -107,7 +99,6 @@ function ToggleGroupControlOptionBase( ), [ cx, isDeselectable, isIcon, isPressed, size, className ] ); - const backdropClasses = useMemo( () => cx( styles.backdropView ), [ cx ] ); const buttonOnClick = () => { if ( isDeselectable && isPressed ) { @@ -124,8 +115,15 @@ function ToggleGroupControlOptionBase( ref: forwardedRef, }; + const labelRef = useRef< HTMLDivElement | null >( null ); + useLayoutEffect( () => { + if ( isPressed && labelRef.current ) { + toggleGroupControlContext.setSelectedElement( labelRef.current ); + } + }, [ isPressed, toggleGroupControlContext ] ); + return ( - + ) } - { /* Animated backdrop using framer motion's shared layout animation */ } - { isPressed ? ( - - - - ) : null } ); } diff --git a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts index 86efc5224077f4..c0248f9b3f7f22 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control-option-base/styles.ts @@ -70,7 +70,7 @@ export const buttonView = ( { } &:active { - background: ${ CONFIG.toggleGroupControlBackgroundColor }; + background: ${ CONFIG.controlBackgroundColor }; } ${ isDeselectable && deselectable } @@ -119,14 +119,3 @@ const isIconStyles = ( { padding-right: 0; `; }; - -export const backdropView = css` - background: ${ COLORS.gray[ 900 ] }; - border-radius: ${ CONFIG.radiusXSmall }; - position: absolute; - inset: 0; - z-index: 1; - // Windows High Contrast mode will show this outline, but not the box-shadow. - outline: 2px solid transparent; - outline-offset: -3px; -`; diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx index b3f56bccd07c5f..7ce762b6e71df2 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx @@ -26,6 +26,7 @@ function UnforwardedToggleGroupControlAsButtonGroup( size, value: valueProp, id: idProp, + setSelectedElement, ...otherProps }: WordPressComponentProps< ToggleGroupControlMainControlProps, @@ -54,16 +55,23 @@ function UnforwardedToggleGroupControlAsButtonGroup( } ); const groupContextValue = useMemo( - () => - ( { - baseId, - value: selectedValue, - setValue: setSelectedValue, - isBlock: ! isAdaptiveWidth, - isDeselectable: true, - size, - } ) as ToggleGroupControlContextProps, - [ baseId, selectedValue, setSelectedValue, isAdaptiveWidth, size ] + (): ToggleGroupControlContextProps => ( { + baseId, + value: selectedValue, + setValue: setSelectedValue, + isBlock: ! isAdaptiveWidth, + isDeselectable: true, + size, + setSelectedElement, + } ), + [ + baseId, + selectedValue, + setSelectedValue, + isAdaptiveWidth, + size, + setSelectedElement, + ] ); return ( diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx index 6baadd65dc5ff6..342f9f128defd9 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx @@ -10,6 +10,7 @@ import { useStoreState } from '@ariakit/react'; */ import { useInstanceId } from '@wordpress/compose'; import { forwardRef, useMemo } from '@wordpress/element'; +import { isRTL } from '@wordpress/i18n'; /** * Internal dependencies @@ -32,6 +33,7 @@ function UnforwardedToggleGroupControlAsRadioGroup( size, value: valueProp, id: idProp, + setSelectedElement, ...otherProps }: WordPressComponentProps< ToggleGroupControlMainControlProps, @@ -65,21 +67,31 @@ function UnforwardedToggleGroupControlAsRadioGroup( defaultValue, value, setValue: wrappedOnChangeProp, + rtl: isRTL(), } ); const selectedValue = useStoreState( radio, 'value' ); const setValue = radio.setValue; const groupContextValue = useMemo( - () => - ( { - baseId, - isBlock: ! isAdaptiveWidth, - size, - value: selectedValue, - setValue, - } ) as ToggleGroupControlContextProps, - [ baseId, isAdaptiveWidth, size, selectedValue, setValue ] + (): ToggleGroupControlContextProps => ( { + baseId, + isBlock: ! isAdaptiveWidth, + size, + // @ts-expect-error - This is wrong and we should fix it. + value: selectedValue, + // @ts-expect-error - This is wrong and we should fix it. + setValue, + setSelectedElement, + } ), + [ + baseId, + isAdaptiveWidth, + selectedValue, + setSelectedElement, + setValue, + size, + ] ); return ( diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx index 1c86c93548f6df..cdf8a2c04eb0b8 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx +++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx @@ -2,13 +2,11 @@ * External dependencies */ import type { ForwardedRef } from 'react'; -import { LayoutGroup } from 'framer-motion'; /** * WordPress dependencies */ -import { useInstanceId } from '@wordpress/compose'; -import { useMemo } from '@wordpress/element'; +import { useLayoutEffect, useMemo, useState } from '@wordpress/element'; /** * Internal dependencies @@ -22,6 +20,104 @@ import { VisualLabelWrapper } from './styles'; import * as styles from './styles'; import { ToggleGroupControlAsRadioGroup } from './as-radio-group'; import { ToggleGroupControlAsButtonGroup } from './as-button-group'; +import type { ElementOffsetRect } from '../../utils/element-rect'; +import { useTrackElementOffsetRect } from '../../utils/element-rect'; +import { useOnValueUpdate } from '../../utils/hooks/use-on-value-update'; +import { useEvent, useMergeRefs } from '@wordpress/compose'; + +/** + * A utility used to animate something in a container component based on the "offset + * rect" (position relative to the container and size) of a subelement. For example, + * this is useful to render an indicator for the selected option of a component, and + * to animate it when the selected option changes. + * + * Takes in a container element and the up-to-date "offset rect" of the target + * subelement, obtained with `useTrackElementOffsetRect`. Then it does the following: + * + * - Adds CSS variables with rect information to the container, so that the indicator + * can be rendered and animated with them. These are kept up-to-date, enabling CSS + * transitions on change. + * - Sets an attribute (`data-subelement-animated` by default) when the tracked + * element changes, so that the target (e.g. the indicator) can be animated to its + * new size and position. + * - Removes the attribute when the animation is done. + * + * The need for the attribute is due to the fact that the rect might update in + * situations other than when the tracked element changes, e.g. the tracked element + * might be resized. In such cases, there is no need to animate the indicator, and + * the change in size or position of the indicator needs to be reflected immediately. + */ +function useAnimatedOffsetRect( + /** + * The container element. + */ + container: HTMLElement | undefined, + /** + * The rect of the tracked element. + */ + rect: ElementOffsetRect, + { + prefix = 'subelement', + dataAttribute = `${ prefix }-animated`, + transitionEndFilter = () => true, + }: { + /** + * The prefix used for the CSS variables, e.g. if `prefix` is `selected`, the + * CSS variables will be `--selected-top`, `--selected-left`, etc. + * @default 'subelement' + */ + prefix?: string; + /** + * The name of the data attribute used to indicate that the animation is in + * progress. The `data-` prefix is added automatically. + * + * For example, if `dataAttribute` is `indicator-animated`, the attribute will + * be `data-indicator-animated`. + * @default `${ prefix }-animated` + */ + dataAttribute?: string; + /** + * A function that is called with the transition event and returns a boolean + * indicating whether the animation should be stopped. The default is a function + * that always returns `true`. + * + * For example, if the animated element is the `::before` pseudo-element, the + * function can be written as `( event ) => event.pseudoElement === '::before'`. + * @default () => true + */ + transitionEndFilter?: ( event: TransitionEvent ) => boolean; + } = {} +) { + const setProperties = useEvent( () => { + ( Object.keys( rect ) as Array< keyof typeof rect > ).forEach( + ( property ) => + property !== 'element' && + container?.style.setProperty( + `--${ prefix }-${ property }`, + String( rect[ property ] ) + ) + ); + } ); + useLayoutEffect( () => { + setProperties(); + }, [ rect, setProperties ] ); + useOnValueUpdate( rect.element, ( { previousValue } ) => { + // Only enable the animation when moving from one element to another. + if ( rect.element && previousValue ) { + container?.setAttribute( `data-${ dataAttribute }`, '' ); + } + } ); + useLayoutEffect( () => { + function onTransitionEnd( event: TransitionEvent ) { + if ( transitionEndFilter( event ) ) { + container?.removeAttribute( `data-${ dataAttribute }` ); + } + } + container?.addEventListener( 'transitionend', onTransitionEnd ); + return () => + container?.removeEventListener( 'transitionend', onTransitionEnd ); + }, [ dataAttribute, container, transitionEndFilter ] ); +} function UnconnectedToggleGroupControl( props: WordPressComponentProps< ToggleGroupControlProps, 'div', false >, @@ -44,10 +140,21 @@ function UnconnectedToggleGroupControl( ...otherProps } = useContextSystem( props, 'ToggleGroupControl' ); - const baseId = useInstanceId( ToggleGroupControl, 'toggle-group-control' ); const normalizedSize = __next40pxDefaultSize && size === 'default' ? '__unstable-large' : size; + const [ selectedElement, setSelectedElement ] = useState< HTMLElement >(); + const [ controlElement, setControlElement ] = useState< HTMLElement >(); + const refs = useMergeRefs( [ setControlElement, forwardedRef ] ); + const selectedRect = useTrackElementOffsetRect( + value ? selectedElement : undefined + ); + useAnimatedOffsetRect( controlElement, selectedRect, { + prefix: 'selected', + dataAttribute: 'indicator-animated', + transitionEndFilter: ( event ) => event.pseudoElement === '::before', + } ); + const cx = useCx(); const classes = useMemo( @@ -81,15 +188,16 @@ function UnconnectedToggleGroupControl( ) } - { children } + { children } ); diff --git a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts index 8d01c150a45eaf..bb6efe476b2b2c 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/styles.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control/styles.ts @@ -26,6 +26,47 @@ export const toggleGroupControl = ( { ${ toggleGroupControlSize( size ) } ${ ! isDeselectable && enclosingBorders( isBlock ) } + + @media not ( prefers-reduced-motion ) { + &[data-indicator-animated]::before { + transition-property: transform, border-radius; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + } + + &::before { + content: ''; + position: absolute; + pointer-events: none; + background: ${ COLORS.gray[ 900 ] }; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: -3px; + + /* Using a large value to avoid antialiasing rounding issues + when scaling in the transform, see: https://stackoverflow.com/a/52159123 */ + --antialiasing-factor: 100; + /* Adjusting the border radius to match the scaling in the x axis. */ + border-radius: calc( + ${ CONFIG.radiusXSmall } / + ( + var( --selected-width, 0 ) / + var( --antialiasing-factor ) + ) + ) / ${ CONFIG.radiusXSmall }; + left: -1px; // Correcting for border. + width: calc( var( --antialiasing-factor ) * 1px ); + height: calc( var( --selected-height, 0 ) * 1px ); + transform-origin: left top; + transform: translateX( calc( var( --selected-left, 0 ) * 1px ) ) + scaleX( + calc( + var( --selected-width, 0 ) / var( --antialiasing-factor ) + ) + ); + } `; const enclosingBorders = ( isBlock: ToggleGroupControlProps[ 'isBlock' ] ) => { diff --git a/packages/components/src/toggle-group-control/types.ts b/packages/components/src/toggle-group-control/types.ts index d49ef3cbb77cb4..2a4af680263dba 100644 --- a/packages/components/src/toggle-group-control/types.ts +++ b/packages/components/src/toggle-group-control/types.ts @@ -137,9 +137,11 @@ export type ToggleGroupControlContextProps = { size: ToggleGroupControlProps[ 'size' ]; value: ToggleGroupControlProps[ 'value' ]; setValue: ( newValue: string | number | undefined ) => void; + setSelectedElement: ( element: HTMLElement | undefined ) => void; }; export type ToggleGroupControlMainControlProps = Pick< ToggleGroupControlProps, 'children' | 'isAdaptiveWidth' | 'label' | 'size' | 'onChange' | 'value' ->; +> & + Pick< ToggleGroupControlContextProps, 'setSelectedElement' >; diff --git a/packages/components/src/tools-panel/tools-panel/README.md b/packages/components/src/tools-panel/tools-panel/README.md index df41b623eefb6c..1daa7537335e1c 100644 --- a/packages/components/src/tools-panel/tools-panel/README.md +++ b/packages/components/src/tools-panel/tools-panel/README.md @@ -60,7 +60,7 @@ import styled from '@emotion/styled'; * WordPress dependencies */ import { - __experimentalBoxControl as BoxControl, + BoxControl, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, __experimentalUnitControl as UnitControl, @@ -91,8 +91,8 @@ export function DimensionPanel() { return ( - Select dimensions or spacing related settings from the - menu for additional controls. + Select dimensions or spacing related settings from the menu for + additional controls. !! height } @@ -154,8 +154,8 @@ export function DimensionPanel() { Flags that the items in this ToolsPanel will be contained within an inner wrapper element allowing the panel to lay them out accordingly. -- Required: No -- Default: `false` +- Required: No +- Default: `false` ### `dropdownMenuProps`: `{}` @@ -176,7 +176,7 @@ The heading level of the panel's header. Text to be displayed within the panel's header and as the `aria-label` for the panel's dropdown menu. -- Required: Yes +- Required: Yes ### `panelId`: `string | null` @@ -185,13 +185,13 @@ to restrict panel items. When a `panelId` is set, items can only register themselves if the `panelId` is explicitly `null` or the item's `panelId` matches exactly. -- Required: No +- Required: No ### `resetAll`: `( filters?: ResetAllFilter[] ) => void` A function to call when the `Reset all` menu option is selected. As an argument, it receives an array containing the `resetAllFilter` callbacks of all the valid registered `ToolsPanelItems`. -- Required: Yes +- Required: Yes ### `shouldRenderPlaceholderItems`: `boolean` @@ -201,5 +201,5 @@ placeholder content (instead of `null`) when they are toggled off and hidden. Note that placeholder items won't apply the `className` that would be normally applied to a visible `ToolsPanelItem` via the `className` prop. -- Required: No -- Default: `false` +- Required: No +- Default: `false` diff --git a/packages/components/src/tools-panel/tools-panel/hook.ts b/packages/components/src/tools-panel/tools-panel/hook.ts index 931bf2494e6e34..583a079ab20026 100644 --- a/packages/components/src/tools-panel/tools-panel/hook.ts +++ b/packages/components/src/tools-panel/tools-panel/hook.ts @@ -5,8 +5,8 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, - useState, } from '@wordpress/element'; /** @@ -27,14 +27,40 @@ import type { const DEFAULT_COLUMNS = 2; +type PanelItemsState = { + panelItems: ToolsPanelItem[]; + menuItemOrder: string[]; + menuItems: ToolsPanelMenuItems; +}; + +type PanelItemsAction = + | { type: 'REGISTER_PANEL'; item: ToolsPanelItem } + | { type: 'UNREGISTER_PANEL'; label: string } + | { + type: 'UPDATE_VALUE'; + group: ToolsPanelMenuItemKey; + label: string; + value: boolean; + } + | { type: 'TOGGLE_VALUE'; label: string } + | { type: 'RESET_ALL' }; + +function emptyMenuItems(): ToolsPanelMenuItems { + return { default: {}, optional: {} }; +} + +function emptyState(): PanelItemsState { + return { panelItems: [], menuItemOrder: [], menuItems: emptyMenuItems() }; +} + const generateMenuItems = ( { panelItems, shouldReset, currentMenuItems, menuItemOrder, }: ToolsPanelMenuItemsConfig ) => { - const newMenuItems: ToolsPanelMenuItems = { default: {}, optional: {} }; - const menuItems: ToolsPanelMenuItems = { default: {}, optional: {} }; + const newMenuItems: ToolsPanelMenuItems = emptyMenuItems(); + const menuItems: ToolsPanelMenuItems = emptyMenuItems(); panelItems.forEach( ( { hasValue, isShownByDefault, label } ) => { const group = isShownByDefault ? 'default' : 'optional'; @@ -75,9 +101,149 @@ const generateMenuItems = ( { return menuItems; }; +function panelItemsReducer( + panelItems: ToolsPanelItem[], + action: PanelItemsAction +) { + switch ( action.type ) { + case 'REGISTER_PANEL': { + const newItems = [ ...panelItems ]; + // If an item with this label has already been registered, remove it + // first. This can happen when an item is moved between the default + // and optional groups. + const existingIndex = newItems.findIndex( + ( oldItem ) => oldItem.label === action.item.label + ); + if ( existingIndex !== -1 ) { + newItems.splice( existingIndex, 1 ); + } + newItems.push( action.item ); + return newItems; + } + case 'UNREGISTER_PANEL': { + const index = panelItems.findIndex( + ( item ) => item.label === action.label + ); + if ( index !== -1 ) { + const newItems = [ ...panelItems ]; + newItems.splice( index, 1 ); + return newItems; + } + return panelItems; + } + default: + return panelItems; + } +} + +function menuItemOrderReducer( + menuItemOrder: string[], + action: PanelItemsAction +) { + switch ( action.type ) { + case 'REGISTER_PANEL': { + // Track the initial order of item registration. This is used for + // maintaining menu item order later. + if ( menuItemOrder.includes( action.item.label ) ) { + return menuItemOrder; + } + + return [ ...menuItemOrder, action.item.label ]; + } + default: + return menuItemOrder; + } +} + +function menuItemsReducer( state: PanelItemsState, action: PanelItemsAction ) { + switch ( action.type ) { + case 'REGISTER_PANEL': + case 'UNREGISTER_PANEL': + // generate new menu items from original `menuItems` and updated `panelItems` and `menuItemOrder` + return generateMenuItems( { + currentMenuItems: state.menuItems, + panelItems: state.panelItems, + menuItemOrder: state.menuItemOrder, + shouldReset: false, + } ); + case 'RESET_ALL': + return generateMenuItems( { + panelItems: state.panelItems, + menuItemOrder: state.menuItemOrder, + shouldReset: true, + } ); + case 'UPDATE_VALUE': { + const oldValue = state.menuItems[ action.group ][ action.label ]; + if ( action.value === oldValue ) { + return state.menuItems; + } + return { + ...state.menuItems, + [ action.group ]: { + ...state.menuItems[ action.group ], + [ action.label ]: action.value, + }, + }; + } + case 'TOGGLE_VALUE': { + const currentItem = state.panelItems.find( + ( item ) => item.label === action.label + ); + + if ( ! currentItem ) { + return state.menuItems; + } + + const menuGroup = currentItem.isShownByDefault + ? 'default' + : 'optional'; + + const newMenuItems = { + ...state.menuItems, + [ menuGroup ]: { + ...state.menuItems[ menuGroup ], + [ action.label ]: + ! state.menuItems[ menuGroup ][ action.label ], + }, + }; + return newMenuItems; + } + + default: + return state.menuItems; + } +} + +function panelReducer( state: PanelItemsState, action: PanelItemsAction ) { + const panelItems = panelItemsReducer( state.panelItems, action ); + const menuItemOrder = menuItemOrderReducer( state.menuItemOrder, action ); + // `menuItemsReducer` is a bit unusual because it generates new state from original `menuItems` + // and the updated `panelItems` and `menuItemOrder`. + const menuItems = menuItemsReducer( + { panelItems, menuItemOrder, menuItems: state.menuItems }, + action + ); + + return { panelItems, menuItemOrder, menuItems }; +} + +function resetAllFiltersReducer( + filters: ResetAllFilter[], + action: { type: 'REGISTER' | 'UNREGISTER'; filter: ResetAllFilter } +) { + switch ( action.type ) { + case 'REGISTER': + return [ ...filters, action.filter ]; + case 'UNREGISTER': + return filters.filter( ( f ) => f !== action.filter ); + default: + return filters; + } +} + const isMenuItemTypeEmpty = ( - obj?: ToolsPanelMenuItems[ ToolsPanelMenuItemKey ] -) => obj && Object.keys( obj ).length === 0; + obj: ToolsPanelMenuItems[ ToolsPanelMenuItemKey ] +) => Object.keys( obj ).length === 0; export function useToolsPanel( props: WordPressComponentProps< ToolsPanelProps, 'div' > @@ -108,103 +274,43 @@ export function useToolsPanel( }, [ wasResetting ] ); // Allow panel items to register themselves. - const [ panelItems, setPanelItems ] = useState< ToolsPanelItem[] >( [] ); - const [ menuItemOrder, setMenuItemOrder ] = useState< string[] >( [] ); - const [ resetAllFilters, setResetAllFilters ] = useState< - ResetAllFilter[] - >( [] ); - - const registerPanelItem = useCallback( - ( item: ToolsPanelItem ) => { - // Add item to panel items. - setPanelItems( ( items ) => { - const newItems = [ ...items ]; - // If an item with this label has already been registered, remove it - // first. This can happen when an item is moved between the default - // and optional groups. - const existingIndex = newItems.findIndex( - ( oldItem ) => oldItem.label === item.label - ); - if ( existingIndex !== -1 ) { - newItems.splice( existingIndex, 1 ); - } - return [ ...newItems, item ]; - } ); - - // Track the initial order of item registration. This is used for - // maintaining menu item order later. - setMenuItemOrder( ( items ) => { - if ( items.includes( item.label ) ) { - return items; - } + const [ { panelItems, menuItems }, panelDispatch ] = useReducer( + panelReducer, + undefined, + emptyState + ); - return [ ...items, item.label ]; - } ); - }, - [ setPanelItems, setMenuItemOrder ] + const [ resetAllFilters, dispatchResetAllFilters ] = useReducer( + resetAllFiltersReducer, + [] ); + const registerPanelItem = useCallback( ( item: ToolsPanelItem ) => { + // Add item to panel items. + panelDispatch( { type: 'REGISTER_PANEL', item } ); + }, [] ); + // Panels need to deregister on unmount to avoid orphans in menu state. // This is an issue when panel items are being injected via SlotFills. - const deregisterPanelItem = useCallback( - ( label: string ) => { - // When switching selections between components injecting matching - // controls, e.g. both panels have a "padding" control, the - // deregistration of the first panel doesn't occur until after the - // registration of the next. - setPanelItems( ( items ) => { - const newItems = [ ...items ]; - const index = newItems.findIndex( - ( item ) => item.label === label - ); - if ( index !== -1 ) { - newItems.splice( index, 1 ); - } - return newItems; - } ); - }, - [ setPanelItems ] - ); - - const registerResetAllFilter = useCallback( - ( newFilter: ResetAllFilter ) => { - setResetAllFilters( ( filters ) => { - return [ ...filters, newFilter ]; - } ); - }, - [ setResetAllFilters ] - ); + const deregisterPanelItem = useCallback( ( label: string ) => { + // When switching selections between components injecting matching + // controls, e.g. both panels have a "padding" control, the + // deregistration of the first panel doesn't occur until after the + // registration of the next. + panelDispatch( { type: 'UNREGISTER_PANEL', label } ); + }, [] ); + + const registerResetAllFilter = useCallback( ( filter: ResetAllFilter ) => { + dispatchResetAllFilters( { type: 'REGISTER', filter } ); + }, [] ); const deregisterResetAllFilter = useCallback( - ( filterToRemove: ResetAllFilter ) => { - setResetAllFilters( ( filters ) => { - return filters.filter( - ( filter ) => filter !== filterToRemove - ); - } ); + ( filter: ResetAllFilter ) => { + dispatchResetAllFilters( { type: 'UNREGISTER', filter } ); }, - [ setResetAllFilters ] + [] ); - // Manage and share display state of menu items representing child controls. - const [ menuItems, setMenuItems ] = useState< ToolsPanelMenuItems >( { - default: {}, - optional: {}, - } ); - - // Setup menuItems state as panel items register themselves. - useEffect( () => { - setMenuItems( ( prevState ) => { - const items = generateMenuItems( { - panelItems, - shouldReset: false, - currentMenuItems: prevState, - menuItemOrder, - } ); - return items; - } ); - }, [ panelItems, setMenuItems, menuItemOrder ] ); - // Updates the status of the panelā€™s menu items. For default items the // value represents whether it differs from the default and for optional // items whether the item is shown. @@ -214,38 +320,24 @@ export function useToolsPanel( label: string, group: ToolsPanelMenuItemKey = 'default' ) => { - setMenuItems( ( items ) => { - const newState = { - ...items, - [ group ]: { - ...items[ group ], - [ label ]: value, - }, - }; - return newState; - } ); + panelDispatch( { type: 'UPDATE_VALUE', group, label, value } ); }, - [ setMenuItems ] + [] ); // Whether all optional menu items are hidden or not must be tracked // in order to later determine if the panel display is empty and handle // conditional display of a plus icon to indicate the presence of further // menu items. - const [ areAllOptionalControlsHidden, setAreAllOptionalControlsHidden ] = - useState( false ); - - useEffect( () => { - if ( - isMenuItemTypeEmpty( menuItems?.default ) && - ! isMenuItemTypeEmpty( menuItems?.optional ) - ) { - const allControlsHidden = ! Object.entries( - menuItems.optional - ).some( ( [ , isSelected ] ) => isSelected ); - setAreAllOptionalControlsHidden( allControlsHidden ); - } - }, [ menuItems, setAreAllOptionalControlsHidden ] ); + const areAllOptionalControlsHidden = useMemo( () => { + return ( + isMenuItemTypeEmpty( menuItems.default ) && + ! isMenuItemTypeEmpty( menuItems.optional ) && + Object.values( menuItems.optional ).every( + ( isSelected ) => ! isSelected + ) + ); + }, [ menuItems ] ); const cx = useCx(); const classes = useMemo( () => { @@ -253,9 +345,7 @@ export function useToolsPanel( hasInnerWrapper && styles.ToolsPanelWithInnerWrapper( DEFAULT_COLUMNS ); const emptyStyle = - isMenuItemTypeEmpty( menuItems?.default ) && - areAllOptionalControlsHidden && - styles.ToolsPanelHiddenInnerWrapper; + areAllOptionalControlsHidden && styles.ToolsPanelHiddenInnerWrapper; return cx( styles.ToolsPanel( DEFAULT_COLUMNS ), @@ -263,42 +353,13 @@ export function useToolsPanel( emptyStyle, className ); - }, [ - areAllOptionalControlsHidden, - className, - cx, - hasInnerWrapper, - menuItems, - ] ); + }, [ areAllOptionalControlsHidden, className, cx, hasInnerWrapper ] ); // Toggle the checked state of a menu item which is then used to determine // display of the item within the panel. - const toggleItem = useCallback( - ( label: string ) => { - const currentItem = panelItems.find( - ( item ) => item.label === label - ); - - if ( ! currentItem ) { - return; - } - - const menuGroup = currentItem.isShownByDefault - ? 'default' - : 'optional'; - - const newMenuItems = { - ...menuItems, - [ menuGroup ]: { - ...menuItems[ menuGroup ], - [ label ]: ! menuItems[ menuGroup ][ label ], - }, - }; - - setMenuItems( newMenuItems ); - }, - [ menuItems, panelItems, setMenuItems ] - ); + const toggleItem = useCallback( ( label: string ) => { + panelDispatch( { type: 'TOGGLE_VALUE', label } ); + }, [] ); // Resets display of children and executes resetAll callback if available. const resetAllItems = useCallback( () => { @@ -308,20 +369,15 @@ export function useToolsPanel( } // Turn off display of all non-default items. - const resetMenuItems = generateMenuItems( { - panelItems, - menuItemOrder, - shouldReset: true, - } ); - setMenuItems( resetMenuItems ); - }, [ panelItems, resetAllFilters, resetAll, setMenuItems, menuItemOrder ] ); + panelDispatch( { type: 'RESET_ALL' } ); + }, [ resetAllFilters, resetAll ] ); // Assist ItemGroup styling when there are potentially hidden placeholder // items by identifying first & last items that are toggled on for display. const getFirstVisibleItemLabel = ( items: ToolsPanelItem[] ) => { const optionalItems = menuItems.optional || {}; const firstItem = items.find( - ( item ) => item.isShownByDefault || !! optionalItems[ item.label ] + ( item ) => item.isShownByDefault || optionalItems[ item.label ] ); return firstItem?.label; @@ -332,6 +388,8 @@ export function useToolsPanel( [ ...panelItems ].reverse() ); + const hasMenuItems = panelItems.length > 0; + const panelContext = useMemo( () => ( { areAllOptionalControlsHidden, @@ -339,7 +397,7 @@ export function useToolsPanel( deregisterResetAllFilter, firstDisplayedItem, flagItemCustomization, - hasMenuItems: !! panelItems.length, + hasMenuItems, isResetting: isResettingRef.current, lastDisplayedItem, menuItems, @@ -359,7 +417,7 @@ export function useToolsPanel( lastDisplayedItem, menuItems, panelId, - panelItems, + hasMenuItems, registerResetAllFilter, registerPanelItem, shouldRenderPlaceholderItems, diff --git a/packages/components/src/utils/config-values.js b/packages/components/src/utils/config-values.js index 2040f479a231c2..1bc3945f9b3b16 100644 --- a/packages/components/src/utils/config-values.js +++ b/packages/components/src/utils/config-values.js @@ -7,18 +7,13 @@ import { COLORS } from './colors-values'; const CONTROL_HEIGHT = '36px'; const CONTROL_PROPS = { - controlSurfaceColor: COLORS.white, - controlTextActiveColor: COLORS.theme.accent, - // These values should be shared with TextControl. controlPaddingX: 12, controlPaddingXSmall: 8, controlPaddingXLarge: 12 * 1.3334, // TODO: Deprecate controlBackgroundColor: COLORS.white, - controlBoxShadow: 'transparent', controlBoxShadowFocus: `0 0 0 0.5px ${ COLORS.theme.accent }`, - controlDestructiveBorderColor: COLORS.alert.red, controlHeight: CONTROL_HEIGHT, controlHeightXSmall: `calc( ${ CONTROL_HEIGHT } * 0.6 )`, controlHeightSmall: `calc( ${ CONTROL_HEIGHT } * 0.8 )`, @@ -26,18 +21,9 @@ const CONTROL_PROPS = { controlHeightXLarge: `calc( ${ CONTROL_HEIGHT } * 1.4 )`, }; -const TOGGLE_GROUP_CONTROL_PROPS = { - toggleGroupControlBackgroundColor: CONTROL_PROPS.controlBackgroundColor, - toggleGroupControlBorderColor: COLORS.ui.border, - toggleGroupControlBackdropBackgroundColor: - CONTROL_PROPS.controlSurfaceColor, - toggleGroupControlBackdropBorderColor: COLORS.ui.border, - toggleGroupControlButtonColorActive: CONTROL_PROPS.controlBackgroundColor, -}; - // Using Object.assign to avoid creating circular references when emitting // TypeScript type declarations. -export default Object.assign( {}, CONTROL_PROPS, TOGGLE_GROUP_CONTROL_PROPS, { +export default Object.assign( {}, CONTROL_PROPS, { colorDivider: 'rgba(0, 0, 0, 0.1)', colorScrollbarThumb: 'rgba(0, 0, 0, 0.2)', colorScrollbarThumbHover: 'rgba(0, 0, 0, 0.5)', diff --git a/packages/components/src/utils/element-rect.ts b/packages/components/src/utils/element-rect.ts index 550ec35b0bc932..7c83db4428ca0f 100644 --- a/packages/components/src/utils/element-rect.ts +++ b/packages/components/src/utils/element-rect.ts @@ -3,16 +3,16 @@ * WordPress dependencies */ import { useLayoutEffect, useRef, useState } from '@wordpress/element'; -import { useResizeObserver } from '@wordpress/compose'; -/** - * Internal dependencies - */ -import { useEvent } from './hooks/use-event'; +import { useEvent, useResizeObserver } from '@wordpress/compose'; /** * The position and dimensions of an element, relative to its offset parent. */ export type ElementOffsetRect = { + /** + * The element the rect belongs to. + */ + element: HTMLElement | undefined; /** * The distance from the top edge of the offset parent to the top edge of * the element. @@ -47,6 +47,7 @@ export type ElementOffsetRect = { * An `ElementOffsetRect` object with all values set to zero. */ export const NULL_ELEMENT_OFFSET_RECT = { + element: undefined, top: 0, right: 0, bottom: 0, @@ -79,9 +80,11 @@ export function getElementOffsetRect( if ( rect.width === 0 || rect.height === 0 ) { return; } + const offsetParent = element.offsetParent; const offsetParentRect = - element.offsetParent?.getBoundingClientRect() ?? - NULL_ELEMENT_OFFSET_RECT; + offsetParent?.getBoundingClientRect() ?? NULL_ELEMENT_OFFSET_RECT; + const offsetParentScrollX = offsetParent?.scrollLeft ?? 0; + const offsetParentScrollY = offsetParent?.scrollTop ?? 0; // Computed widths and heights have subpixel precision, and are not affected // by distortions. @@ -94,13 +97,22 @@ export function getElementOffsetRect( const scaleY = computedHeight / rect.height; return { + element, // To obtain the adjusted values for the position: // 1. Compute the element's position relative to the offset parent. // 2. Correct for the scale factor. - top: ( rect.top - offsetParentRect?.top ) * scaleY, - right: ( offsetParentRect?.right - rect.right ) * scaleX, - bottom: ( offsetParentRect?.bottom - rect.bottom ) * scaleY, - left: ( rect.left - offsetParentRect?.left ) * scaleX, + // 3. Adjust for the scroll position of the offset parent. + top: + ( rect.top - offsetParentRect?.top ) * scaleY + offsetParentScrollY, + right: + ( offsetParentRect?.right - rect.right ) * scaleX - + offsetParentScrollX, + bottom: + ( offsetParentRect?.bottom - rect.bottom ) * scaleY - + offsetParentScrollY, + left: + ( rect.left - offsetParentRect?.left ) * scaleX + + offsetParentScrollX, // Computed dimensions don't need any adjustments. width: computedWidth, height: computedHeight, @@ -113,6 +125,9 @@ const POLL_RATE = 100; * Tracks the position and dimensions of an element, relative to its offset * parent. The element can be changed dynamically. * + * When no element is provided (`null` or `undefined`), the hook will return + * a "null" rect, in which all values are `0` and `element` is `undefined`. + * * **Note:** sometimes, the measurement will fail (see `getElementOffsetRect`'s * documentation for more details). When that happens, this hook will attempt * to measure again after a frame, and if that fails, it will poll every 100 @@ -149,10 +164,12 @@ export function useTrackElementOffsetRect( } } ); - useLayoutEffect( - () => setElement( targetElement ), - [ setElement, targetElement ] - ); + useLayoutEffect( () => { + setElement( targetElement ); + if ( ! targetElement ) { + setIndicatorPosition( NULL_ELEMENT_OFFSET_RECT ); + } + }, [ setElement, targetElement ] ); return indicatorPosition; } diff --git a/packages/components/src/utils/hooks/use-event.ts b/packages/components/src/utils/hooks/use-event.ts deleted file mode 100644 index eefac9478a8b4f..00000000000000 --- a/packages/components/src/utils/hooks/use-event.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable jsdoc/require-param */ -/** - * WordPress dependencies - */ -import { useRef, useInsertionEffect, useCallback } from '@wordpress/element'; - -/** - * Any function. - */ -export type AnyFunction = ( ...args: any ) => any; - -/** - * Creates a stable callback function that has access to the latest state and - * can be used within event handlers and effect callbacks. Throws when used in - * the render phase. - * - * @example - * - * ```tsx - * function Component(props) { - * const onClick = useEvent(props.onClick); - * React.useEffect(() => {}, [onClick]); - * } - * ``` - */ -export function useEvent< T extends AnyFunction >( callback?: T ) { - const ref = useRef< AnyFunction | undefined >( () => { - throw new Error( 'Cannot call an event handler while rendering.' ); - } ); - useInsertionEffect( () => { - ref.current = callback; - } ); - return useCallback< AnyFunction >( - ( ...args ) => ref.current?.( ...args ), - [] - ) as T; -} -/* eslint-enable jsdoc/require-param */ diff --git a/packages/components/src/utils/hooks/use-on-value-update.ts b/packages/components/src/utils/hooks/use-on-value-update.ts index 5726f3977daf04..15cfc321359e7c 100644 --- a/packages/components/src/utils/hooks/use-on-value-update.ts +++ b/packages/components/src/utils/hooks/use-on-value-update.ts @@ -2,11 +2,8 @@ /** * WordPress dependencies */ -import { useRef, useEffect } from '@wordpress/element'; -/** - * Internal dependencies - */ -import { useEvent } from './use-event'; +import { useEvent } from '@wordpress/compose'; +import { useRef, useLayoutEffect } from '@wordpress/element'; /** * Context object for the `onUpdate` callback of `useOnValueUpdate`. @@ -30,7 +27,7 @@ export function useOnValueUpdate< T >( ) { const previousValueRef = useRef( value ); const updateCallbackEvent = useEvent( onUpdate ); - useEffect( () => { + useLayoutEffect( () => { if ( previousValueRef.current !== value ) { updateCallbackEvent( { previousValue: previousValueRef.current, diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md index 18c21a65b9b124..28269dca692a4f 100644 --- a/packages/compose/CHANGELOG.md +++ b/packages/compose/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- `useResizeObserver`: export legacy API at top-level for React Native ([#65588](https://github.com/WordPress/gutenberg/pull/65588)). + ## 7.8.0 (2024-09-19) ### New Features diff --git a/packages/compose/src/hooks/use-resize-observer/index.native.js b/packages/compose/src/hooks/use-resize-observer/index.native.js new file mode 100644 index 00000000000000..79eb3e569e332a --- /dev/null +++ b/packages/compose/src/hooks/use-resize-observer/index.native.js @@ -0,0 +1 @@ +export { default } from './legacy/index.native'; diff --git a/packages/compose/src/hooks/use-resize-observer/index.ts b/packages/compose/src/hooks/use-resize-observer/index.ts index 2a76b2aa6ab590..1bd0f074cc49f2 100644 --- a/packages/compose/src/hooks/use-resize-observer/index.ts +++ b/packages/compose/src/hooks/use-resize-observer/index.ts @@ -1,53 +1,14 @@ -/** - * WordPress dependencies - */ -import { useRef } from '@wordpress/element'; /** * Internal dependencies */ -import useEvent from '../use-event'; -import type { ObservedSize } from './_legacy'; -import _useLegacyResizeObserver from './_legacy'; +import { useResizeObserver as _useResizeObserver } from './use-resize-observer'; +import type { ObservedSize } from './legacy'; +import _useLegacyResizeObserver from './legacy'; /** * External dependencies */ import type { ReactElement } from 'react'; -// This is the current implementation of `useResizeObserver`. -// -// The legacy implementation is still supported for backwards compatibility. -// This is achieved by overloading the exported function with both signatures, -// and detecting which API is being used at runtime. -function _useResizeObserver< T extends HTMLElement >( - callback: ResizeObserverCallback, - resizeObserverOptions: ResizeObserverOptions = {} -): ( element?: T | null ) => void { - const callbackEvent = useEvent( callback ); - - const observedElementRef = useRef< T | null >(); - const resizeObserverRef = useRef< ResizeObserver >(); - return useEvent( ( element?: T | null ) => { - if ( element === observedElementRef.current ) { - return; - } - observedElementRef.current = element; - - // Set up `ResizeObserver`. - resizeObserverRef.current ??= new ResizeObserver( callbackEvent ); - const { current: resizeObserver } = resizeObserverRef; - - // Unobserve previous element. - if ( observedElementRef.current ) { - resizeObserver.unobserve( observedElementRef.current ); - } - - // Observe new element. - if ( element ) { - resizeObserver.observe( element, resizeObserverOptions ); - } - } ); -} - /** * Sets up a [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API) * for an HTML or SVG element. diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/index.native.js b/packages/compose/src/hooks/use-resize-observer/legacy/index.native.js similarity index 100% rename from packages/compose/src/hooks/use-resize-observer/_legacy/index.native.js rename to packages/compose/src/hooks/use-resize-observer/legacy/index.native.js diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx b/packages/compose/src/hooks/use-resize-observer/legacy/index.tsx similarity index 98% rename from packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx rename to packages/compose/src/hooks/use-resize-observer/legacy/index.tsx index b44bd841964164..fe765810982226 100644 --- a/packages/compose/src/hooks/use-resize-observer/_legacy/index.tsx +++ b/packages/compose/src/hooks/use-resize-observer/legacy/index.tsx @@ -10,7 +10,7 @@ import { useCallback, useRef, useState } from '@wordpress/element'; /** * Internal dependencies */ -import useResizeObserver from '../index'; +import { useResizeObserver } from '../use-resize-observer'; export type ObservedSize = { width: number | null; diff --git a/packages/compose/src/hooks/use-resize-observer/_legacy/test/index.native.js b/packages/compose/src/hooks/use-resize-observer/legacy/test/index.native.js similarity index 100% rename from packages/compose/src/hooks/use-resize-observer/_legacy/test/index.native.js rename to packages/compose/src/hooks/use-resize-observer/legacy/test/index.native.js diff --git a/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts b/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts new file mode 100644 index 00000000000000..4c1031b9839dc3 --- /dev/null +++ b/packages/compose/src/hooks/use-resize-observer/use-resize-observer.ts @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { useRef } from '@wordpress/element'; +/** + * Internal dependencies + */ +import useEvent from '../use-event'; + +// This is the current implementation of `useResizeObserver`. +// +// The legacy implementation is still supported for backwards compatibility. +// This is achieved by overloading the exported function with both signatures, +// and detecting which API is being used at runtime. +export function useResizeObserver< T extends HTMLElement >( + callback: ResizeObserverCallback, + resizeObserverOptions: ResizeObserverOptions = {} +): ( element?: T | null ) => void { + const callbackEvent = useEvent( callback ); + + const observedElementRef = useRef< T | null >(); + const resizeObserverRef = useRef< ResizeObserver >(); + return useEvent( ( element?: T | null ) => { + if ( element === observedElementRef.current ) { + return; + } + + // Set up `ResizeObserver`. + resizeObserverRef.current ??= new ResizeObserver( callbackEvent ); + const { current: resizeObserver } = resizeObserverRef; + + // Unobserve previous element. + if ( observedElementRef.current ) { + resizeObserver.unobserve( observedElementRef.current ); + } + + // Observe new element. + observedElementRef.current = element; + if ( element ) { + resizeObserver.observe( element, resizeObserverOptions ); + } + } ); +} diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index 8334ef97c9244d..a2d3c76ebe5d9c 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -37,6 +37,7 @@ "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", + "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", "@wordpress/router": "file:../router", "@wordpress/url": "file:../url" diff --git a/packages/core-commands/src/admin-navigation-commands.js b/packages/core-commands/src/admin-navigation-commands.js index 0ffa7ba7eb6285..c0d8bb084b46ad 100644 --- a/packages/core-commands/src/admin-navigation-commands.js +++ b/packages/core-commands/src/admin-navigation-commands.js @@ -1,9 +1,92 @@ /** * WordPress dependencies */ -import { useCommand } from '@wordpress/commands'; +import { useCommand, useCommandLoader } from '@wordpress/commands'; import { __ } from '@wordpress/i18n'; import { plus } from '@wordpress/icons'; +import { getPath } from '@wordpress/url'; +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback, useMemo } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; + +const { useHistory } = unlock( routerPrivateApis ); + +function useAddNewPageCommand() { + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + const history = useHistory(); + const isBlockBasedTheme = useSelect( ( select ) => { + return select( coreStore ).getCurrentTheme()?.is_block_theme; + }, [] ); + const { saveEntityRecord } = useDispatch( coreStore ); + const { createErrorNotice } = useDispatch( noticesStore ); + + const createPageEntity = useCallback( + async ( { close } ) => { + try { + const page = await saveEntityRecord( + 'postType', + 'page', + { + status: 'draft', + }, + { + throwOnError: true, + } + ); + if ( page?.id ) { + history.push( { + postId: page.id, + postType: 'page', + canvas: 'edit', + } ); + } + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while creating the item.' ); + + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } finally { + close(); + } + }, + [ createErrorNotice, history, saveEntityRecord ] + ); + + const commands = useMemo( () => { + const addNewPage = + isSiteEditor && isBlockBasedTheme + ? createPageEntity + : () => + ( document.location.href = + 'post-new.php?post_type=page' ); + return [ + { + name: 'core/add-new-page', + label: __( 'Add new page' ), + icon: plus, + callback: addNewPage, + }, + ]; + }, [ createPageEntity, isSiteEditor, isBlockBasedTheme ] ); + + return { + isLoading: false, + commands, + }; +} export function useAdminNavigationCommands() { useCommand( { @@ -14,12 +97,9 @@ export function useAdminNavigationCommands() { document.location.href = 'post-new.php'; }, } ); - useCommand( { + + useCommandLoader( { name: 'core/add-new-page', - label: __( 'Add new page' ), - icon: plus, - callback: () => { - document.location.href = 'post-new.php?post_type=page'; - }, + hook: useAddNewPageCommand, } ); } diff --git a/packages/core-data/src/test/entity-provider.js b/packages/core-data/src/test/entity-provider.js index 6b0b7bd5ef77a8..4dc0d8a51663e8 100644 --- a/packages/core-data/src/test/entity-provider.js +++ b/packages/core-data/src/test/entity-provider.js @@ -104,7 +104,7 @@ describe( 'useEntityBlockEditor', () => { source: 'html', selector: 'p', default: '', - __experimentalRole: 'content', + role: 'content', }, }, title: 'block title', diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md index 348c8466836c69..24dcfd52b7b586 100644 --- a/packages/create-block-interactive-template/CHANGELOG.md +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -1,9 +1,11 @@ -## Unreleased - ## 2.8.0 (2024-09-19) +### Enhancements + +- Added TypeScript variant of the template ([#64577](https://github.com/WordPress/gutenberg/pull/64577)). + ## 2.7.0 (2024-09-05) ### Enhancements diff --git a/packages/create-block-interactive-template/README.md b/packages/create-block-interactive-template/README.md index 4417c647495c4c..b50adb49265245 100644 --- a/packages/create-block-interactive-template/README.md +++ b/packages/create-block-interactive-template/README.md @@ -1,6 +1,6 @@ # Create Block Interactive Template -This is a template for [`@wordpress/create-block`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/create-block/README.md) to create interactive blocks +This is a template for [`@wordpress/create-block`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/create-block/README.md) to create interactive blocks. ## Usage diff --git a/packages/create-block-interactive-template/block-templates/README.md.mustache b/packages/create-block-interactive-template/block-templates/README.md.mustache index 3e64ce8f629a3c..4a13743750f748 100644 --- a/packages/create-block-interactive-template/block-templates/README.md.mustache +++ b/packages/create-block-interactive-template/block-templates/README.md.mustache @@ -3,6 +3,4 @@ > **Note** > Check the [Interactivity API Reference docs in the Block Editor handbook](https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/) to learn more about the Interactivity API. -{{#isBasicVariant}} This block has been created with the `create-block-interactive-template` and shows a basic structure of an interactive block that uses the Interactivity API. -{{/isBasicVariant}} \ No newline at end of file diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache index 3a41a2981cd8cf..4f84b30dbcdbdd 100644 --- a/packages/create-block-interactive-template/block-templates/render.php.mustache +++ b/packages/create-block-interactive-template/block-templates/render.php.mustache @@ -1,4 +1,3 @@ -{{#isBasicVariant}} false, + 'darkText' => esc_html__( 'Switch to Light', '{{textdomain}}' ), + 'lightText' => esc_html__( 'Switch to Dark', '{{textdomain}}' ), + 'themeText' => esc_html__( 'Switch to Dark', '{{textdomain}}' ), + ) +); ?>
false ) ); ?> data-wp-watch="callbacks.logIsOpen" + data-wp-class--dark-theme="state.isDark" > + +
-{{/isBasicVariant}} diff --git a/packages/create-block-interactive-template/block-templates/style.scss.mustache b/packages/create-block-interactive-template/block-templates/style.scss.mustache index 1c73fa1c38ff94..c8aa9f232136e2 100644 --- a/packages/create-block-interactive-template/block-templates/style.scss.mustache +++ b/packages/create-block-interactive-template/block-templates/style.scss.mustache @@ -9,4 +9,19 @@ font-size: 1em; background: #ffff001a; padding: 1em; + + &.dark-theme { + background: #333; + color: #fff; + + button { + background: #555; + color: #fff; + border: 1px solid #777; + } + + p { + color: #ddd; + } + } } diff --git a/packages/create-block-interactive-template/block-templates/view.js.mustache b/packages/create-block-interactive-template/block-templates/view.js.mustache index b4bae3939461dd..3fcf1ba365d265 100644 --- a/packages/create-block-interactive-template/block-templates/view.js.mustache +++ b/packages/create-block-interactive-template/block-templates/view.js.mustache @@ -1,15 +1,23 @@ -{{#isBasicVariant}} +{{#isDefaultVariant}} /** * WordPress dependencies */ -import { store, getContext } from "@wordpress/interactivity"; +import { store, getContext } from '@wordpress/interactivity'; -store( '{{namespace}}', { +const { state } = store( '{{namespace}}', { + state: { + get themeText() { + return state.isDark ? state.darkText : state.lightText; + } + }, actions: { - toggle: () => { + toggleOpen() { const context = getContext(); context.isOpen = ! context.isOpen; }, + toggleTheme() { + state.isDark = ! state.isDark; + } }, callbacks: { logIsOpen: () => { @@ -19,5 +27,4 @@ store( '{{namespace}}', { }, }, } ); - -{{/isBasicVariant}} +{{/isDefaultVariant}} diff --git a/packages/create-block-interactive-template/block-templates/view.ts.mustache b/packages/create-block-interactive-template/block-templates/view.ts.mustache new file mode 100644 index 00000000000000..11670442d73704 --- /dev/null +++ b/packages/create-block-interactive-template/block-templates/view.ts.mustache @@ -0,0 +1,46 @@ +{{#isTypescriptVariant}} +/** + * WordPress dependencies + */ +import { store, getContext } from '@wordpress/interactivity'; + +type ServerState = { + state: { + isDark: boolean; + darkText: string; + lightText: string; + }; +}; + +type Context = { + isOpen: boolean; +}; + +const storeDef = { + state: { + get themeText(): string { + return state.isDark ? state.darkText : state.lightText; + } + }, + actions: { + toggleOpen() { + const context = getContext< Context >(); + context.isOpen = ! context.isOpen; + }, + toggleTheme() { + state.isDark = ! state.isDark; + } + }, + callbacks: { + logIsOpen: () => { + const { isOpen } = getContext< Context >(); + // Log the value of `isOpen` each time it changes. + console.log( `Is open: ${ isOpen }` ); + }, + }, +}; + +type Store = ServerState & typeof storeDef; + +const { state } = store< Store >( '{{namespace}}', storeDef ); +{{/isTypescriptVariant}} diff --git a/packages/create-block-interactive-template/index.js b/packages/create-block-interactive-template/index.js index bb203b7023e281..94f615df2747f2 100644 --- a/packages/create-block-interactive-template/index.js +++ b/packages/create-block-interactive-template/index.js @@ -7,7 +7,7 @@ module.exports = { defaultValues: { slug: 'example-interactive', title: 'Example Interactive', - description: 'An interactive block with the Interactivity API', + description: 'An interactive block with the Interactivity API.', dashicon: 'media-interactive', npmDependencies: [ '@wordpress/interactivity' ], customPackageJSON: { files: [ '[^.]*' ] }, @@ -24,7 +24,14 @@ module.exports = { }, }, variants: { - basic: {}, + default: {}, + typescript: { + slug: 'example-interactive-typescript', + title: 'Example Interactive TypeScript', + description: + 'An interactive block with the Interactivity API using TypeScript.', + viewScriptModule: 'file:./view.ts', + }, }, pluginTemplatesPath: join( __dirname, 'plugin-templates' ), blockTemplatesPath: join( __dirname, 'block-templates' ), diff --git a/packages/customize-widgets/src/components/error-boundary/index.js b/packages/customize-widgets/src/components/error-boundary/index.js index 49867787afd059..0fff18a616d11c 100644 --- a/packages/customize-widgets/src/components/error-boundary/index.js +++ b/packages/customize-widgets/src/components/error-boundary/index.js @@ -11,12 +11,7 @@ import { doAction } from '@wordpress/hooks'; function CopyButton( { text, children } ) { const ref = useCopyToClipboard( text ); return ( - ); diff --git a/packages/customize-widgets/src/components/inserter/index.js b/packages/customize-widgets/src/components/inserter/index.js index 41fc037cf673c9..4f271bef9e9a3f 100644 --- a/packages/customize-widgets/src/components/inserter/index.js +++ b/packages/customize-widgets/src/components/inserter/index.js @@ -37,9 +37,7 @@ function Inserter( { setIsOpened } ) { { __( 'Add a block' ) } +
+ diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php new file mode 100644 index 00000000000000..bdaec8d1b67a9d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.asset.php @@ -0,0 +1,9 @@ + array( + '@wordpress/interactivity', + array( + 'id' => '@wordpress/interactivity-router', + 'import' => 'dynamic', + ), + ), +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js new file mode 100644 index 00000000000000..83f016e2eac16a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +import { store, getContext, getServerContext } from '@wordpress/interactivity'; + +store( 'test/get-server-context', { + actions: { + *navigate( e ) { + e.preventDefault(); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( e.target.href ); + }, + attemptModification() { + try { + getServerContext().prop = 'updated from client'; + getContext().result = 'unexpectedly modified āŒ'; + } catch ( e ) { + getContext().result = 'not modified āœ…'; + } + }, + }, + callbacks: { + updateServerContextParent() { + const ctx = getContext(); + const { prop, newProp, nested, inherited } = getServerContext(); + ctx.prop = prop; + ctx.newProp = newProp; + ctx.nested.prop = nested.prop; + ctx.nested.newProp = nested.newProp; + ctx.inherited.prop = inherited.prop; + ctx.inherited.newProp = inherited.newProp; + }, + updateServerContextChild() { + const ctx = getContext(); + const { prop, newProp, nested, inherited } = getServerContext(); + ctx.prop = prop; + ctx.newProp = newProp; + ctx.nested.prop = nested.prop; + ctx.nested.newProp = nested.newProp; + ctx.inherited.prop = inherited.prop; + ctx.inherited.newProp = inherited.newProp; + }, + }, +} ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json new file mode 100644 index 00000000000000..abf76eb9beddcc --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/get-server-state", + "title": "E2E Interactivity tests - getServerState", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScriptModule": "file:./view.js", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php new file mode 100644 index 00000000000000..abc4efd8272d5b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php @@ -0,0 +1,50 @@ + + +
+
+
+
+
+ + + + + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php new file mode 100644 index 00000000000000..bdaec8d1b67a9d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.asset.php @@ -0,0 +1,9 @@ + array( + '@wordpress/interactivity', + array( + 'id' => '@wordpress/interactivity-router', + 'import' => 'dynamic', + ), + ), +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js new file mode 100644 index 00000000000000..db2992ec4a5863 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { store, getServerState, getContext } from '@wordpress/interactivity'; + +const { state } = store( 'test/get-server-state', { + actions: { + *navigate( e ) { + e.preventDefault(); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( e.target.href ); + }, + attemptModification() { + try { + getServerState().prop = 'updated from client'; + getContext().result = 'unexpectedly modified āŒ'; + } catch ( e ) { + getContext().result = 'not modified āœ…'; + } + }, + }, + callbacks: { + updateState() { + const { prop, newProp, nested } = getServerState(); + state.prop = prop; + state.newProp = newProp; + state.nested.prop = nested.prop; + state.nested.newProp = nested.newProp; + }, + }, +} ); diff --git a/packages/edit-post/src/components/back-button/fullscreen-mode-close.js b/packages/edit-post/src/components/back-button/fullscreen-mode-close.js index 626212cbab0542..ffb64a8ba07035 100644 --- a/packages/edit-post/src/components/back-button/fullscreen-mode-close.js +++ b/packages/edit-post/src/components/back-button/fullscreen-mode-close.js @@ -91,8 +91,7 @@ function FullscreenModeClose( { showTooltip, icon, href, initialPost } ) { return (
@@ -586,24 +586,24 @@ exports[`PostPublishPanel should render the pre-publish panel if the post is not class="editor-post-publish-panel__header" >
@@ -783,24 +783,24 @@ exports[`PostPublishPanel should render the spinner if the post is being saved 1 class="editor-post-publish-panel__header" >
diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js index ecc5bc610a3027..0fbb2beb62665e 100644 --- a/packages/editor/src/components/preview-dropdown/index.js +++ b/packages/editor/src/components/preview-dropdown/index.js @@ -26,7 +26,9 @@ import { ActionItem } from '@wordpress/interface'; * Internal dependencies */ import { store as editorStore } from '../../store'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import PostPreviewButton from '../post-preview-button'; +import { unlock } from '../../lock-unlock'; export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) { const { deviceType, homeUrl, isTemplate, isViewable, showIconLabels } = @@ -44,6 +46,14 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) { }; }, [] ); const { setDeviceType } = useDispatch( editorStore ); + const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); + const { resetZoomLevel } = unlock( useDispatch( blockEditorStore ) ); + + const handleDevicePreviewChange = ( newDeviceType ) => { + setDeviceType( newDeviceType ); + __unstableSetEditorMode( 'edit' ); + resetZoomLevel(); + }; const isMobile = useViewportMatch( 'medium', '<' ); if ( isMobile ) { @@ -113,7 +123,7 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) { { isTemplate && ( diff --git a/packages/editor/src/components/provider/disable-non-page-content-blocks.js b/packages/editor/src/components/provider/disable-non-page-content-blocks.js index 9abb0e14079d5e..ae4fd1075fc261 100644 --- a/packages/editor/src/components/provider/disable-non-page-content-blocks.js +++ b/packages/editor/src/components/provider/disable-non-page-content-blocks.js @@ -3,52 +3,32 @@ */ import { useSelect, useRegistry } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; -import { useEffect, useMemo } from '@wordpress/element'; -import { applyFilters } from '@wordpress/hooks'; +import { useEffect } from '@wordpress/element'; /** * Internal dependencies */ -import { store as editorStore } from '../../store'; -import { unlock } from '../../lock-unlock'; - -const POST_CONTENT_BLOCK_TYPES = [ - 'core/post-title', - 'core/post-featured-image', - 'core/post-content', -]; +import usePostContentBlocks from './use-post-content-blocks'; /** * Component that when rendered, makes it so that the site editor allows only * page content to be edited. */ export default function DisableNonPageContentBlocks() { - const contentOnlyBlockTypes = useMemo( - () => [ - ...applyFilters( - 'editor.postContentBlockTypes', - POST_CONTENT_BLOCK_TYPES - ), - 'core/template-part', - ], - [] - ); - - // Note that there are two separate subscriptions because the result for each - // returns a new array. - const contentOnlyIds = useSelect( + const contentOnlyIds = usePostContentBlocks(); + const templateParts = useSelect( ( select ) => { + const { getBlocksByName } = select( blockEditorStore ); + return getBlocksByName( 'core/template-part' ); + }, [] ); + const disabledIds = useSelect( ( select ) => { - const { getPostBlocksByName } = unlock( select( editorStore ) ); - return getPostBlocksByName( contentOnlyBlockTypes ); + const { getBlockOrder } = select( blockEditorStore ); + return templateParts.flatMap( ( clientId ) => + getBlockOrder( clientId ) + ); }, - [ contentOnlyBlockTypes ] + [ templateParts ] ); - const disabledIds = useSelect( ( select ) => { - const { getBlocksByName, getBlockOrder } = select( blockEditorStore ); - return getBlocksByName( 'core/template-part' ).flatMap( ( clientId ) => - getBlockOrder( clientId ) - ); - }, [] ); const registry = useRegistry(); @@ -61,6 +41,9 @@ export default function DisableNonPageContentBlocks() { for ( const clientId of contentOnlyIds ) { setBlockEditingMode( clientId, 'contentOnly' ); } + for ( const clientId of templateParts ) { + setBlockEditingMode( clientId, 'contentOnly' ); + } for ( const clientId of disabledIds ) { setBlockEditingMode( clientId, 'disabled' ); } @@ -72,12 +55,15 @@ export default function DisableNonPageContentBlocks() { for ( const clientId of contentOnlyIds ) { unsetBlockEditingMode( clientId ); } + for ( const clientId of templateParts ) { + unsetBlockEditingMode( clientId ); + } for ( const clientId of disabledIds ) { unsetBlockEditingMode( clientId ); } } ); }; - }, [ contentOnlyIds, disabledIds, registry ] ); + }, [ templateParts, contentOnlyIds, disabledIds, registry ] ); return null; } diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 11b1478d58434a..0c45dbc5e7199c 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -188,26 +188,19 @@ export const ExperimentalEditorProvider = withRegistryProvider( const postContext = {}; // If it is a template, try to inherit the post type from the slug. if ( post.type === 'wp_template' ) { - if ( ! post.is_custom ) { - const [ kind ] = post.slug.split( '-' ); - switch ( kind ) { - case 'page': - postContext.postType = 'page'; - break; - case 'single': - // Infer the post type from the slug. - const postTypesSlugs = - postTypes?.map( ( entity ) => entity.slug ) || - []; - const match = post.slug.match( - `^single-(${ postTypesSlugs.join( - '|' - ) })(?:-.+)?$` - ); - if ( match ) { - postContext.postType = match[ 1 ]; - } - break; + if ( post.slug === 'page' ) { + postContext.postType = 'page'; + } else if ( post.slug === 'single' ) { + postContext.postType = 'post'; + } else if ( post.slug.split( '-' )[ 0 ] === 'single' ) { + // If the slug is single-{postType}, infer the post type from the slug. + const postTypesSlugs = + postTypes?.map( ( entity ) => entity.slug ) || []; + const match = post.slug.match( + `^single-(${ postTypesSlugs.join( '|' ) })(?:-.+)?$` + ); + if ( match ) { + postContext.postType = match[ 1 ]; } } } else if ( diff --git a/packages/editor/src/components/provider/use-post-content-blocks.js b/packages/editor/src/components/provider/use-post-content-blocks.js new file mode 100644 index 00000000000000..bdd277157e47e0 --- /dev/null +++ b/packages/editor/src/components/provider/use-post-content-blocks.js @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +const POST_CONTENT_BLOCK_TYPES = [ + 'core/post-title', + 'core/post-featured-image', + 'core/post-content', +]; + +export default function usePostContentBlocks() { + const contentOnlyBlockTypes = useMemo( + () => [ + ...applyFilters( + 'editor.postContentBlockTypes', + POST_CONTENT_BLOCK_TYPES + ), + ], + [] + ); + + // Note that there are two separate subscriptions because the result for each + // returns a new array. + const contentOnlyIds = useSelect( + ( select ) => { + const { getPostBlocksByName } = unlock( select( editorStore ) ); + return getPostBlocksByName( contentOnlyBlockTypes ); + }, + [ contentOnlyBlockTypes ] + ); + + return contentOnlyIds; +} diff --git a/packages/editor/src/components/resizable-editor/resize-handle.js b/packages/editor/src/components/resizable-editor/resize-handle.js index dbba31f6f998ba..ccd903d0f3a172 100644 --- a/packages/editor/src/components/resizable-editor/resize-handle.js +++ b/packages/editor/src/components/resizable-editor/resize-handle.js @@ -15,6 +15,11 @@ export default function ResizeHandle( { direction, resizeWidthBy } ) { function handleKeyDown( event ) { const { keyCode } = event; + if ( keyCode !== LEFT && keyCode !== RIGHT ) { + return; + } + event.preventDefault(); + if ( ( direction === 'left' && keyCode === LEFT ) || ( direction === 'right' && keyCode === RIGHT ) diff --git a/packages/editor/src/components/visual-editor/index.js b/packages/editor/src/components/visual-editor/index.js index 2ff115272d614b..88d2dac8ffd77c 100644 --- a/packages/editor/src/components/visual-editor/index.js +++ b/packages/editor/src/components/visual-editor/index.js @@ -174,17 +174,19 @@ function VisualEditor( { hasRootPaddingAwareAlignments, themeHasDisabledLayoutStyles, themeSupportsLayout, - isZoomOutMode, + isZoomedOut, } = useSelect( ( select ) => { - const { getSettings, __unstableGetEditorMode } = - select( blockEditorStore ); + const { getSettings, isZoomOut: _isZoomOut } = unlock( + select( blockEditorStore ) + ); + const _settings = getSettings(); return { themeHasDisabledLayoutStyles: _settings.disableLayoutStyles, themeSupportsLayout: _settings.supportsLayout, hasRootPaddingAwareAlignments: _settings.__experimentalFeatures?.useRootPaddingAwareAlignments, - isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', + isZoomedOut: _isZoomOut(), }; }, [] ); @@ -336,7 +338,7 @@ function VisualEditor( { ] ); const zoomOutProps = - isZoomOutMode && ! isTabletViewport + isZoomedOut && ! isTabletViewport ? { scale: 'default', frameSize: '48px', @@ -355,7 +357,7 @@ function VisualEditor( { // Disable resizing in mobile viewport. ! isMobileViewport && // Dsiable resizing in zoomed-out mode. - ! isZoomOutMode; + ! isZoomedOut; const shouldIframe = ! disableIframe || [ 'Tablet', 'Mobile' ].includes( deviceType ); diff --git a/packages/editor/src/components/zoom-out-toggle/index.js b/packages/editor/src/components/zoom-out-toggle/index.js index e8c7b1e50510ab..b89bf15546f0d8 100644 --- a/packages/editor/src/components/zoom-out-toggle/index.js +++ b/packages/editor/src/components/zoom-out-toggle/index.js @@ -7,26 +7,43 @@ import { __ } from '@wordpress/i18n'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { square as zoomOutIcon } from '@wordpress/icons'; +import { store as preferencesStore } from '@wordpress/preferences'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; const ZoomOutToggle = () => { - const { isZoomOutMode } = useSelect( ( select ) => ( { - isZoomOutMode: - select( blockEditorStore ).__unstableGetEditorMode() === 'zoom-out', + const { isZoomOut, showIconLabels } = useSelect( ( select ) => ( { + isZoomOut: unlock( select( blockEditorStore ) ).isZoomOut(), + showIconLabels: select( preferencesStore ).get( + 'core', + 'showIconLabels' + ), } ) ); - const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); + const { resetZoomLevel, setZoomLevel, __unstableSetEditorMode } = unlock( + useDispatch( blockEditorStore ) + ); const handleZoomOut = () => { - __unstableSetEditorMode( isZoomOutMode ? 'edit' : 'zoom-out' ); + if ( isZoomOut ) { + resetZoomLevel(); + } else { + setZoomLevel( 50 ); + } + __unstableSetEditorMode( isZoomOut ? 'edit' : 'zoom-out' ); }; return ( - - - - ); - }, -}; - -export default deletePostAction; diff --git a/packages/editor/src/dataviews/actions/reset-post.tsx b/packages/editor/src/dataviews/actions/reset-post.tsx deleted file mode 100644 index d0b5521a34833d..00000000000000 --- a/packages/editor/src/dataviews/actions/reset-post.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * WordPress dependencies - */ -import { backup } from '@wordpress/icons'; -import { useDispatch } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; -import { __, sprintf } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; -import { useState } from '@wordpress/element'; -import { - Button, - __experimentalText as Text, - __experimentalHStack as HStack, - __experimentalVStack as VStack, -} from '@wordpress/components'; -import type { Action } from '@wordpress/dataviews'; -import type { StoreDescriptor } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { TEMPLATE_POST_TYPE, TEMPLATE_ORIGINS } from '../../store/constants'; -import { store as editorStore } from '../../store'; -import { unlock } from '../../lock-unlock'; -import type { Post, CoreDataError } from '../types'; -import { isTemplateOrTemplatePart, getItemTitle } from './utils'; - -const resetPost: Action< Post > = { - id: 'reset-post', - label: __( 'Reset' ), - isEligible: ( item ) => { - return ( - isTemplateOrTemplatePart( item ) && - item?.source === TEMPLATE_ORIGINS.custom && - ( Boolean( item.type === 'wp_template' && item?.plugin ) || - item?.has_theme_file ) - ); - }, - icon: backup, - supportsBulk: true, - hideModalHeader: true, - RenderModal: ( { items, closeModal, onActionPerformed } ) => { - const [ isBusy, setIsBusy ] = useState( false ); - const { revertTemplate } = unlock( - useDispatch( editorStore as StoreDescriptor ) - ); - const { saveEditedEntityRecord } = useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - const onConfirm = async () => { - try { - for ( const template of items ) { - await revertTemplate( template, { - allowUndo: false, - } ); - await saveEditedEntityRecord( - 'postType', - template.type, - template.id - ); - } - createSuccessNotice( - items.length > 1 - ? sprintf( - /* translators: The number of items. */ - __( '%s items reset.' ), - items.length - ) - : sprintf( - /* translators: The template/part's name. */ - __( '"%s" reset.' ), - getItemTitle( items[ 0 ] ) - ), - { - type: 'snackbar', - id: 'revert-template-action', - } - ); - } catch ( error ) { - let fallbackErrorMessage; - if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) { - fallbackErrorMessage = - items.length === 1 - ? __( - 'An error occurred while reverting the template.' - ) - : __( - 'An error occurred while reverting the templates.' - ); - } else { - fallbackErrorMessage = - items.length === 1 - ? __( - 'An error occurred while reverting the template part.' - ) - : __( - 'An error occurred while reverting the template parts.' - ); - } - - const typedError = error as CoreDataError; - const errorMessage = - typedError.message && typedError.code !== 'unknown_error' - ? typedError.message - : fallbackErrorMessage; - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - }; - return ( - - - { __( 'Reset to default and clear all customizations?' ) } - - - - - - - ); - }, -}; - -export default resetPost; diff --git a/packages/editor/src/dataviews/fields/index.ts b/packages/editor/src/dataviews/fields/index.ts deleted file mode 100644 index b215172eaf7f02..00000000000000 --- a/packages/editor/src/dataviews/fields/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import type { Field } from '@wordpress/dataviews'; - -/** - * Internal dependencies - */ -import type { BasePost } from '../types'; -import { getItemTitle } from '../actions/utils'; - -export const titleField: Field< BasePost > = { - type: 'text', - id: 'title', - label: __( 'Title' ), - placeholder: __( 'No title' ), - getValue: ( { item } ) => getItemTitle( item ), -}; - -export const orderField: Field< BasePost > = { - type: 'integer', - id: 'menu_order', - label: __( 'Order' ), - description: __( 'Determines the order of pages.' ), -}; diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts index e685493641f3b8..10f2b9ce872d5a 100644 --- a/packages/editor/src/dataviews/store/private-actions.ts +++ b/packages/editor/src/dataviews/store/private-actions.ts @@ -8,11 +8,6 @@ import { doAction } from '@wordpress/hooks'; /** * Internal dependencies */ -import duplicateTemplatePart from '../actions/duplicate-template-part'; -import resetPost from '../actions/reset-post'; -import trashPost from '../actions/trash-post'; -import renamePost from '../actions/rename-post'; -import restorePost from '../actions/restore-post'; import type { PostType } from '../types'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; @@ -24,8 +19,13 @@ import { reorderPage, exportPattern, permanentlyDeletePost, + restorePost, + trashPost, + renamePost, + resetPost, + deletePost, } from '@wordpress/fields'; -import deletePost from '../actions/delete-post'; +import duplicateTemplatePart from '../actions/duplicate-template-part'; export function registerEntityAction< Item >( kind: string, @@ -117,8 +117,8 @@ export const registerPostTypeActions = ? reorderPage : undefined, postTypeConfig.slug === 'wp_block' ? exportPattern : undefined, - resetPost, restorePost, + resetPost, deletePost, trashPost, permanentlyDeletePost, diff --git a/packages/editor/src/hooks/pattern-overrides.js b/packages/editor/src/hooks/pattern-overrides.js index 6f81f368351f38..8882856a89e0d9 100644 --- a/packages/editor/src/hooks/pattern-overrides.js +++ b/packages/editor/src/hooks/pattern-overrides.js @@ -6,7 +6,7 @@ import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useBlockEditingMode } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; -import { store as blocksStore } from '@wordpress/blocks'; +import { getBlockBindingsSource } from '@wordpress/blocks'; /** * Internal dependencies @@ -58,7 +58,6 @@ function ControlsWithStoreSubscription( props ) { const blockEditingMode = useBlockEditingMode(); const { hasPatternOverridesSource, isEditingSyncedPattern } = useSelect( ( select ) => { - const { getBlockBindingsSource } = unlock( select( blocksStore ) ); const { getCurrentPostType, getEditedPostAttribute } = select( editorStore ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 59faa6b5b73624..fa720e1fc7d347 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -12,7 +12,11 @@ import { import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; -import { applyFilters } from '@wordpress/hooks'; +import { + applyFilters, + applyFiltersAsync, + doActionAsync, +} from '@wordpress/hooks'; import { store as preferencesStore } from '@wordpress/preferences'; import { __ } from '@wordpress/i18n'; @@ -26,7 +30,7 @@ import { getNotificationArgumentsForSaveFail, getNotificationArgumentsForTrashFail, } from './utils/notice-builder'; - +import { unlock } from '../lock-unlock'; /** * Returns an action generator used in signalling that editor has initialized with * the specified post object and editor settings. @@ -184,7 +188,7 @@ export const savePost = } const previousRecord = select.getCurrentPost(); - const edits = { + let edits = { id: previousRecord.id, ...registry .select( coreStore ) @@ -199,9 +203,9 @@ export const savePost = let error = false; try { - error = await applyFilters( - 'editor.__unstablePreSavePost', - Promise.resolve( false ), + edits = await applyFiltersAsync( + 'editor.preSavePost', + edits, options ); } catch ( err ) { @@ -236,14 +240,25 @@ export const savePost = ); } + // Run the hook with legacy unstable name for backward compatibility if ( ! error ) { - await applyFilters( - 'editor.__unstableSavePost', - Promise.resolve(), - options - ).catch( ( err ) => { + try { + await applyFilters( + 'editor.__unstableSavePost', + Promise.resolve(), + options + ); + } catch ( err ) { error = err; - } ); + } + } + + if ( ! error ) { + try { + await doActionAsync( 'editor.savePost', options ); + } catch ( err ) { + error = err; + } } dispatch( { type: 'REQUEST_POST_UPDATE_FINISH', options } ); @@ -726,15 +741,32 @@ export function removeEditorPanel( panelName ) { * use an object. * @param {string} value.rootClientId The root client ID to insert at. * @param {number} value.insertionIndex The index to insert at. + * @param {string} value.filterValue A query to filter the inserter results. + * @param {Function} value.onSelect A callback when an item is selected. + * @param {string} value.tab The tab to open in the inserter. + * @param {string} value.category The category to initialize in the inserter. * * @return {Object} Action object. */ -export function setIsInserterOpened( value ) { - return { - type: 'SET_IS_INSERTER_OPENED', - value, +export const setIsInserterOpened = + ( value ) => + ( { dispatch, registry } ) => { + if ( + typeof value === 'object' && + value.hasOwnProperty( 'rootClientId' ) && + value.hasOwnProperty( 'insertionIndex' ) + ) { + unlock( registry.dispatch( blockEditorStore ) ).setInsertionPoint( { + rootClientId: value.rootClientId, + index: value.insertionIndex, + } ); + } + + dispatch( { + type: 'SET_IS_INSERTER_OPENED', + value, + } ); }; -} /** * Returns an action object used to open/close the list view. diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js index 357a7344f631d4..9bc065436c10bb 100644 --- a/packages/editor/src/store/private-selectors.js +++ b/packages/editor/src/store/private-selectors.js @@ -37,13 +37,13 @@ const EMPTY_INSERTION_POINT = { }; /** - * Get the insertion point for the inserter. + * Get the inserter. * * @param {Object} state Global application state. * * @return {Object} The root client ID, index to insert at and starting filter value. */ -export const getInsertionPoint = createRegistrySelector( ( select ) => +export const getInserter = createRegistrySelector( ( select ) => createSelector( ( state ) => { if ( typeof state.blockInserterPanel === 'object' ) { diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index fae30c6fc271ec..206c60a159d04f 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -576,4 +576,80 @@ describe( 'Editor actions', () => { ).toBe( true ); } ); } ); + + describe( 'setIsInserterOpened', () => { + it( 'should open and close the inserter', () => { + const registry = createRegistryWithStores(); + + registry.dispatch( editorStore ).setIsInserterOpened( true ); + + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + true + ); + + registry.dispatch( editorStore ).setIsInserterOpened( false ); + + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + false + ); + } ); + + it( 'the list view should close when the inserter is opened', () => { + const registry = createRegistryWithStores(); + + registry.dispatch( editorStore ).setIsListViewOpened( true ); + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + true + ); + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + false + ); + + registry.dispatch( editorStore ).setIsInserterOpened( true ); + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + true + ); + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + false + ); + } ); + } ); + + describe( 'setIsListViewOpened', () => { + it( 'should open and close the list view', () => { + const registry = createRegistryWithStores(); + + registry.dispatch( editorStore ).setIsListViewOpened( true ); + + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + true + ); + + registry.dispatch( editorStore ).setIsListViewOpened( false ); + + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + false + ); + } ); + + it( 'the inserter should close when the list view is opened', () => { + const registry = createRegistryWithStores(); + + registry.dispatch( editorStore ).setIsInserterOpened( true ); + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + true + ); + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + false + ); + + registry.dispatch( editorStore ).setIsListViewOpened( true ); + expect( registry.select( editorStore ).isListViewOpened() ).toBe( + true + ); + expect( registry.select( editorStore ).isInserterOpened() ).toBe( + false + ); + } ); + } ); } ); diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index b4fd013c6b4d42..3971ad30c9de74 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -18,7 +18,6 @@ import { blockInserterPanel, listViewPanel, } from '../reducer'; -import { setIsInserterOpened } from '../actions'; describe( 'state', () => { describe( 'hasSameKeys()', () => { @@ -298,15 +297,6 @@ describe( 'state', () => { expect( blockInserterPanel( true, {} ) ).toBe( true ); } ); - it( 'should set the open state of the inserter panel', () => { - expect( - blockInserterPanel( false, setIsInserterOpened( true ) ) - ).toBe( true ); - expect( - blockInserterPanel( true, setIsInserterOpened( false ) ) - ).toBe( false ); - } ); - it( 'should close the inserter when opening the list view panel', () => { expect( blockInserterPanel( true, { @@ -349,17 +339,5 @@ describe( 'state', () => { } ) ).toBe( false ); } ); - - it( 'should close the list view when opening the inserter panel', () => { - expect( listViewPanel( true, setIsInserterOpened( true ) ) ).toBe( - false - ); - } ); - - it( 'should not change the state when closing the inserter panel', () => { - expect( listViewPanel( true, setIsInserterOpened( false ) ) ).toBe( - true - ); - } ); } ); } ); diff --git a/packages/fields/README.md b/packages/fields/README.md index 842fab02606af8..b4e45103600da6 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -14,6 +14,10 @@ npm install @wordpress/fields --save +### deletePost + +Undocumented declaration. + ### duplicatePattern Undocumented declaration. @@ -42,6 +46,10 @@ Undocumented declaration. Undocumented declaration. +### renamePost + +Undocumented declaration. + ### reorderPage Undocumented declaration. @@ -50,10 +58,22 @@ Undocumented declaration. Undocumented declaration. +### resetPost + +Undocumented declaration. + +### restorePost + +Undocumented declaration. + ### titleField Undocumented declaration. +### trashPost + +Undocumented declaration. + ### viewPost Undocumented declaration. diff --git a/packages/fields/package.json b/packages/fields/package.json index 2e417c9f4de570..3da913d1ee9ae5 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -9,7 +9,6 @@ "gutenberg", "dataviews" ], - "private": true, "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/fields/README.md", "repository": { "type": "git", @@ -33,6 +32,7 @@ ], "dependencies": { "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", "@wordpress/blocks": "file:../blocks", "@wordpress/components": "file:../components", diff --git a/packages/fields/src/actions/base-post/index.ts b/packages/fields/src/actions/base-post/index.ts deleted file mode 100644 index 7541be86c48b1f..00000000000000 --- a/packages/fields/src/actions/base-post/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as viewPost } from './view-post'; -export { default as reorderPage } from './reorder-page'; -export { default as reorderPageNative } from './reorder-page.native'; -export { default as duplicatePost } from './duplicate-post'; -export { default as duplicatePostNative } from './duplicate-post.native'; diff --git a/packages/fields/src/actions/common/index.ts b/packages/fields/src/actions/common/index.ts deleted file mode 100644 index 3590b2e270892e..00000000000000 --- a/packages/fields/src/actions/common/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as viewPostRevisions } from './view-post-revisions'; -export { default as permanentlyDeletePost } from './permanently-delete-post'; diff --git a/packages/fields/src/actions/delete-post.tsx b/packages/fields/src/actions/delete-post.tsx new file mode 100644 index 00000000000000..c5ab866e12479e --- /dev/null +++ b/packages/fields/src/actions/delete-post.tsx @@ -0,0 +1,203 @@ +/** + * WordPress dependencies + */ +import { trash } from '@wordpress/icons'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +// @ts-ignore +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import type { Action } from '@wordpress/dataviews'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { + getItemTitle, + isTemplateOrTemplatePart, + isTemplateRemovable, +} from './utils'; +import type { Pattern, Template, TemplatePart } from '../types'; +import type { NoticeSettings } from '../mutation'; +import { deletePostWithNotices } from '../mutation'; +import { unlock } from '../lock-unlock'; + +const { PATTERN_TYPES } = unlock( patternsPrivateApis ); + +// This action is used for templates, patterns and template parts. +// Every other post type uses the similar `trashPostAction` which +// moves the post to trash. +const deletePostAction: Action< Template | TemplatePart | Pattern > = { + id: 'delete-post', + label: __( 'Delete' ), + isPrimary: true, + icon: trash, + isEligible( post ) { + if ( isTemplateOrTemplatePart( post ) ) { + return isTemplateRemovable( post ); + } + // We can only remove user patterns. + return post.type === PATTERN_TYPES.user; + }, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); + const isResetting = items.every( + ( item ) => isTemplateOrTemplatePart( item ) && item?.has_theme_file + ); + return ( + + + { items.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + items.length + ), + items.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + getItemTitle( items[ 0 ] ) + ) } + + + + + + + ); + }, +}; + +export default deletePostAction; diff --git a/packages/fields/src/actions/pattern/duplicate-pattern.tsx b/packages/fields/src/actions/duplicate-pattern.tsx similarity index 91% rename from packages/fields/src/actions/pattern/duplicate-pattern.tsx rename to packages/fields/src/actions/duplicate-pattern.tsx index 7c71a271997f15..bf2820f951dbad 100644 --- a/packages/fields/src/actions/pattern/duplicate-pattern.tsx +++ b/packages/fields/src/actions/duplicate-pattern.tsx @@ -9,8 +9,8 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import { unlock } from '../../lock-unlock'; -import type { Pattern } from '../../types'; +import { unlock } from '../lock-unlock'; +import type { Pattern } from '../types'; // Patterns. const { CreatePatternModalContents, useDuplicatePatternProps } = diff --git a/packages/fields/src/actions/base-post/duplicate-post.native.tsx b/packages/fields/src/actions/duplicate-post.native.tsx similarity index 100% rename from packages/fields/src/actions/base-post/duplicate-post.native.tsx rename to packages/fields/src/actions/duplicate-post.native.tsx diff --git a/packages/fields/src/actions/base-post/duplicate-post.tsx b/packages/fields/src/actions/duplicate-post.tsx similarity index 96% rename from packages/fields/src/actions/base-post/duplicate-post.tsx rename to packages/fields/src/actions/duplicate-post.tsx index 0035a40c009342..d153073f4b6c12 100644 --- a/packages/fields/src/actions/base-post/duplicate-post.tsx +++ b/packages/fields/src/actions/duplicate-post.tsx @@ -18,9 +18,9 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import { titleField } from '../../fields'; -import type { BasePost, CoreDataError } from '../../types'; -import { getItemTitle } from '../utils'; +import { titleField } from '../fields'; +import type { BasePost, CoreDataError } from '../types'; +import { getItemTitle } from './utils'; const fields = [ titleField ]; const formDuplicateAction = { diff --git a/packages/fields/src/actions/pattern/export-pattern.native.tsx b/packages/fields/src/actions/export-pattern.native.tsx similarity index 100% rename from packages/fields/src/actions/pattern/export-pattern.native.tsx rename to packages/fields/src/actions/export-pattern.native.tsx diff --git a/packages/fields/src/actions/pattern/export-pattern.tsx b/packages/fields/src/actions/export-pattern.tsx similarity index 95% rename from packages/fields/src/actions/pattern/export-pattern.tsx rename to packages/fields/src/actions/export-pattern.tsx index b0f6c3335544c1..b6be83eeda84b4 100644 --- a/packages/fields/src/actions/pattern/export-pattern.tsx +++ b/packages/fields/src/actions/export-pattern.tsx @@ -15,8 +15,8 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import type { Pattern } from '../../types'; -import { getItemTitle } from '../utils'; +import type { Pattern } from '../types'; +import { getItemTitle } from './utils'; function getJsonFromItem( item: Pattern ) { return JSON.stringify( diff --git a/packages/fields/src/actions/index.ts b/packages/fields/src/actions/index.ts index cf4fd6833f3fbe..08e22836e68fd1 100644 --- a/packages/fields/src/actions/index.ts +++ b/packages/fields/src/actions/index.ts @@ -1,3 +1,15 @@ -export * from './base-post'; -export * from './common'; -export * from './pattern'; +export { default as viewPost } from './view-post'; +export { default as reorderPage } from './reorder-page'; +export { default as reorderPageNative } from './reorder-page.native'; +export { default as duplicatePost } from './duplicate-post'; +export { default as duplicatePostNative } from './duplicate-post.native'; +export { default as renamePost } from './rename-post'; +export { default as resetPost } from './reset-post'; +export { default as duplicatePattern } from './duplicate-pattern'; +export { default as exportPattern } from './export-pattern'; +export { default as exportPatternNative } from './export-pattern.native'; +export { default as viewPostRevisions } from './view-post-revisions'; +export { default as permanentlyDeletePost } from './permanently-delete-post'; +export { default as restorePost } from './restore-post'; +export { default as trashPost } from './trash-post'; +export { default as deletePost } from './delete-post'; diff --git a/packages/fields/src/actions/pattern/index.ts b/packages/fields/src/actions/pattern/index.ts deleted file mode 100644 index 827c2ce365c2c5..00000000000000 --- a/packages/fields/src/actions/pattern/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as duplicatePattern } from './duplicate-pattern'; -export { default as exportPattern } from './export-pattern'; -export { default as exportPatternNative } from './export-pattern.native'; diff --git a/packages/fields/src/actions/common/permanently-delete-post.tsx b/packages/fields/src/actions/permanently-delete-post.tsx similarity index 96% rename from packages/fields/src/actions/common/permanently-delete-post.tsx rename to packages/fields/src/actions/permanently-delete-post.tsx index e0c1de96871f1f..afbb84ae12c74c 100644 --- a/packages/fields/src/actions/common/permanently-delete-post.tsx +++ b/packages/fields/src/actions/permanently-delete-post.tsx @@ -10,8 +10,8 @@ import { trash } from '@wordpress/icons'; /** * Internal dependencies */ -import { getItemTitle, isTemplateOrTemplatePart } from '../utils'; -import type { CoreDataError, PostWithPermissions } from '../../types'; +import { getItemTitle, isTemplateOrTemplatePart } from './utils'; +import type { CoreDataError, PostWithPermissions } from '../types'; const permanentlyDeletePost: Action< PostWithPermissions > = { id: 'permanently-delete', diff --git a/packages/editor/src/dataviews/actions/rename-post.tsx b/packages/fields/src/actions/rename-post.tsx similarity index 97% rename from packages/editor/src/dataviews/actions/rename-post.tsx rename to packages/fields/src/actions/rename-post.tsx index ef9da271111ea2..da1fd46669f0df 100644 --- a/packages/editor/src/dataviews/actions/rename-post.tsx +++ b/packages/fields/src/actions/rename-post.tsx @@ -19,17 +19,16 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import { - TEMPLATE_ORIGINS, - TEMPLATE_PART_POST_TYPE, - TEMPLATE_POST_TYPE, -} from '../../store/constants'; -import { unlock } from '../../lock-unlock'; + +import { unlock } from '../lock-unlock'; import { getItemTitle, isTemplateRemovable, isTemplate, isTemplatePart, + TEMPLATE_ORIGINS, + TEMPLATE_PART_POST_TYPE, + TEMPLATE_POST_TYPE, } from './utils'; import type { CoreDataError, PostWithPermissions } from '../types'; diff --git a/packages/fields/src/actions/base-post/reorder-page.native.tsx b/packages/fields/src/actions/reorder-page.native.tsx similarity index 100% rename from packages/fields/src/actions/base-post/reorder-page.native.tsx rename to packages/fields/src/actions/reorder-page.native.tsx diff --git a/packages/fields/src/actions/base-post/reorder-page.tsx b/packages/fields/src/actions/reorder-page.tsx similarity index 96% rename from packages/fields/src/actions/base-post/reorder-page.tsx rename to packages/fields/src/actions/reorder-page.tsx index 7f3bca59c471ce..1820884d8d8c73 100644 --- a/packages/fields/src/actions/base-post/reorder-page.tsx +++ b/packages/fields/src/actions/reorder-page.tsx @@ -17,8 +17,8 @@ import type { Action, RenderModalProps } from '@wordpress/dataviews'; /** * Internal dependencies */ -import type { CoreDataError, BasePost } from '../../types'; -import { orderField } from '../../fields'; +import type { CoreDataError, BasePost } from '../types'; +import { orderField } from '../fields'; const fields = [ orderField ]; const formOrderAction = { diff --git a/packages/fields/src/actions/reset-post.tsx b/packages/fields/src/actions/reset-post.tsx new file mode 100644 index 00000000000000..105d7b283b8334 --- /dev/null +++ b/packages/fields/src/actions/reset-post.tsx @@ -0,0 +1,300 @@ +/** + * WordPress dependencies + */ +import { backup } from '@wordpress/icons'; +import { dispatch, select, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useState } from '@wordpress/element'; +// @ts-ignore +import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import type { Action } from '@wordpress/dataviews'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { + getItemTitle, + isTemplateOrTemplatePart, + TEMPLATE_ORIGINS, + TEMPLATE_POST_TYPE, +} from './utils'; +import type { CoreDataError, Template, TemplatePart } from '../types'; + +const isTemplateRevertable = ( + templateOrTemplatePart: Template | TemplatePart +) => { + if ( ! templateOrTemplatePart ) { + return false; + } + + return ( + templateOrTemplatePart.source === TEMPLATE_ORIGINS.custom && + ( Boolean( templateOrTemplatePart?.plugin ) || + templateOrTemplatePart?.has_theme_file ) + ); +}; + +/** + * Copied - pasted from https://github.com/WordPress/gutenberg/blob/bf1462ad37d4637ebbf63270b9c244b23c69e2a8/packages/editor/src/store/private-actions.js#L233-L365 + * + * @param {Object} template The template to revert. + * @param {Object} [options] + * @param {boolean} [options.allowUndo] Whether to allow the user to undo + * reverting the template. Default true. + */ +const revertTemplate = async ( + template: TemplatePart | Template, + { allowUndo = true } = {} +) => { + const noticeId = 'edit-site-template-reverted'; + dispatch( noticesStore ).removeNotice( noticeId ); + if ( ! isTemplateRevertable( template ) ) { + dispatch( noticesStore ).createErrorNotice( + __( 'This template is not revertable.' ), + { + type: 'snackbar', + } + ); + return; + } + + try { + const templateEntityConfig = select( coreStore ).getEntityConfig( + 'postType', + template.type + ); + + if ( ! templateEntityConfig ) { + dispatch( noticesStore ).createErrorNotice( + __( + 'The editor has encountered an unexpected error. Please reload.' + ), + { type: 'snackbar' } + ); + return; + } + + const fileTemplatePath = addQueryArgs( + `${ templateEntityConfig.baseURL }/${ template.id }`, + { context: 'edit', source: template.origin } + ); + + const fileTemplate = ( await apiFetch( { + path: fileTemplatePath, + } ) ) as any; + if ( ! fileTemplate ) { + dispatch( noticesStore ).createErrorNotice( + __( + 'The editor has encountered an unexpected error. Please reload.' + ), + { type: 'snackbar' } + ); + return; + } + + const serializeBlocks = ( { blocks: blocksForSerialization = [] } ) => + __unstableSerializeAndClean( blocksForSerialization ); + + const edited = select( coreStore ).getEditedEntityRecord( + 'postType', + template.type, + template.id + ) as any; + + // We are fixing up the undo level here to make sure we can undo + // the revert in the header toolbar correctly. + dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + template.id, + { + content: serializeBlocks, // Required to make the `undo` behave correctly. + blocks: edited.blocks, // Required to revert the blocks in the editor. + source: 'custom', // required to avoid turning the editor into a dirty state + }, + { + undoIgnore: true, // Required to merge this edit with the last undo level. + } + ); + + const blocks = parse( fileTemplate?.content?.raw ); + + dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + fileTemplate.id, + { + content: serializeBlocks, + blocks, + source: 'theme', + } + ); + + if ( allowUndo ) { + const undoRevert = () => { + dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + edited.id, + { + content: serializeBlocks, + blocks: edited.blocks, + source: 'custom', + } + ); + }; + + dispatch( noticesStore ).createSuccessNotice( + __( 'Template reset.' ), + { + type: 'snackbar', + id: noticeId, + actions: [ + { + label: __( 'Undo' ), + onClick: undoRevert, + }, + ], + } + ); + } + } catch ( error: any ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'Template revert failed. Please reload.' ); + + dispatch( noticesStore ).createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } +}; + +const resetPostAction: Action< Template | TemplatePart > = { + id: 'reset-post', + label: __( 'Reset' ), + isEligible: ( item ) => { + return ( + isTemplateOrTemplatePart( item ) && + item?.source === TEMPLATE_ORIGINS.custom && + ( Boolean( item.type === 'wp_template' && item?.plugin ) || + item?.has_theme_file ) + ); + }, + icon: backup, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); + + const { saveEditedEntityRecord } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const onConfirm = async () => { + try { + for ( const template of items ) { + await revertTemplate( template, { + allowUndo: false, + } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + } + createSuccessNotice( + items.length > 1 + ? sprintf( + /* translators: The number of items. */ + __( '%s items reset.' ), + items.length + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reset.' ), + getItemTitle( items[ 0 ] ) + ), + { + type: 'snackbar', + id: 'revert-template-action', + } + ); + } catch ( error ) { + let fallbackErrorMessage; + if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template.' + ) + : __( + 'An error occurred while reverting the templates.' + ); + } else { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template part.' + ) + : __( + 'An error occurred while reverting the template parts.' + ); + } + + const typedError = error as CoreDataError; + const errorMessage = + typedError.message && typedError.code !== 'unknown_error' + ? typedError.message + : fallbackErrorMessage; + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; + return ( + + + { __( 'Reset to default and clear all customizations?' ) } + + + + + + + ); + }, +}; + +export default resetPostAction; diff --git a/packages/editor/src/dataviews/actions/restore-post.tsx b/packages/fields/src/actions/restore-post.tsx similarity index 100% rename from packages/editor/src/dataviews/actions/restore-post.tsx rename to packages/fields/src/actions/restore-post.tsx diff --git a/packages/editor/src/dataviews/actions/trash-post.tsx b/packages/fields/src/actions/trash-post.tsx similarity index 100% rename from packages/editor/src/dataviews/actions/trash-post.tsx rename to packages/fields/src/actions/trash-post.tsx diff --git a/packages/fields/src/actions/common/view-post-revisions.tsx b/packages/fields/src/actions/view-post-revisions.tsx similarity index 96% rename from packages/fields/src/actions/common/view-post-revisions.tsx rename to packages/fields/src/actions/view-post-revisions.tsx index 617a5263a707d6..875b925b94f070 100644 --- a/packages/fields/src/actions/common/view-post-revisions.tsx +++ b/packages/fields/src/actions/view-post-revisions.tsx @@ -8,7 +8,7 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import type { Post } from '../../types'; +import type { Post } from '../types'; const viewPostRevisions: Action< Post > = { id: 'view-post-revisions', diff --git a/packages/fields/src/actions/base-post/view-post.tsx b/packages/fields/src/actions/view-post.tsx similarity index 92% rename from packages/fields/src/actions/base-post/view-post.tsx rename to packages/fields/src/actions/view-post.tsx index 8c581877e473bb..187faffafb5d3c 100644 --- a/packages/fields/src/actions/base-post/view-post.tsx +++ b/packages/fields/src/actions/view-post.tsx @@ -8,7 +8,7 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import type { BasePost } from '../../types'; +import type { BasePost } from '../types'; const viewPost: Action< BasePost > = { id: 'view-post', diff --git a/packages/fields/src/index.native.ts b/packages/fields/src/index.native.ts index e4d3134d72f847..33a26e3c2e6e27 100644 --- a/packages/fields/src/index.native.ts +++ b/packages/fields/src/index.native.ts @@ -1,2 +1,2 @@ -export * from './actions/base-post/duplicate-post.native'; -export * from './actions/base-post/reorder-page.native'; +export * from './actions/duplicate-post.native'; +export * from './actions/reorder-page.native'; diff --git a/packages/fields/src/mutation/index.ts b/packages/fields/src/mutation/index.ts new file mode 100644 index 00000000000000..80e399d74e9479 --- /dev/null +++ b/packages/fields/src/mutation/index.ts @@ -0,0 +1,184 @@ +/** + * WordPress dependencies + */ +import { store as noticesStore } from '@wordpress/notices'; +import { store as coreStore } from '@wordpress/core-data'; +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import type { CoreDataError, Post } from '../types'; + +const getErrorMessagesFromPromises = < T >( + allSettledResults: PromiseSettledResult< T >[] +) => { + const errorMessages = new Set< string >(); + // If there was at lease one failure. + if ( allSettledResults.length === 1 ) { + const typedError = allSettledResults[ 0 ] as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessages.add( typedError.reason.message ); + } + } else { + const failedPromises = allSettledResults.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + const typedError = failedPromise as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessages.add( typedError.reason.message ); + } + } + } + return errorMessages; +}; + +export type NoticeSettings< T extends Post > = { + success: { + id?: string; + type?: string; + messages: { + getMessage: ( posts: T ) => string; + getBatchMessage: ( posts: T[] ) => string; + }; + }; + error: { + id?: string; + type?: string; + messages: { + getMessage: ( errors: Set< string > ) => string; + getBatchMessage: ( errors: Set< string > ) => string; + }; + }; +}; + +export const deletePostWithNotices = async < T extends Post >( + posts: T[], + notice: NoticeSettings< T >, + callbacks: { + onActionPerformed?: ( posts: T[] ) => void; + onActionError?: () => void; + } +) => { + const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore ); + const { deleteEntityRecord } = dispatch( coreStore ); + const allSettledResults = await Promise.allSettled( + posts.map( ( post ) => { + return deleteEntityRecord( + 'postType', + post.type, + post.id, + { force: true }, + { throwOnError: true } + ); + } ) + ); + // If all the promises were fulfilled with success. + if ( allSettledResults.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + if ( allSettledResults.length === 1 ) { + successMessage = notice.success.messages.getMessage( posts[ 0 ] ); + } else { + successMessage = notice.success.messages.getBatchMessage( posts ); + } + createSuccessNotice( successMessage, { + type: notice.success.type ?? 'snackbar', + id: notice.success.id, + } ); + callbacks.onActionPerformed?.( posts ); + } else { + const errorMessages = getErrorMessagesFromPromises( allSettledResults ); + let errorMessage = ''; + if ( allSettledResults.length === 1 ) { + errorMessage = notice.error.messages.getMessage( errorMessages ); + } else { + errorMessage = + notice.error.messages.getBatchMessage( errorMessages ); + } + + createErrorNotice( errorMessage, { + type: notice.error.type ?? 'snackbar', + id: notice.error.id, + } ); + callbacks.onActionError?.(); + } +}; + +export const editPostWithNotices = async < T extends Post >( + postsWithUpdates: { + originalPost: T; + changes: Partial< T >; + }[], + notice: NoticeSettings< T >, + callbacks: { + onActionPerformed?: ( posts: T[] ) => void; + onActionError?: () => void; + } +) => { + const { createSuccessNotice, createErrorNotice } = dispatch( noticesStore ); + const { editEntityRecord, saveEditedEntityRecord } = dispatch( coreStore ); + await Promise.allSettled( + postsWithUpdates.map( ( post ) => { + return editEntityRecord( + 'postType', + post.originalPost.type, + post.originalPost.id, + { + ...post.changes, + } + ); + } ) + ); + const allSettledResults = await Promise.allSettled( + postsWithUpdates.map( ( post ) => { + return saveEditedEntityRecord( + 'postType', + post.originalPost.type, + post.originalPost.id, + { + throwOnError: true, + } + ); + } ) + ); + // If all the promises were fulfilled with success. + if ( allSettledResults.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + if ( allSettledResults.length === 1 ) { + successMessage = notice.success.messages.getMessage( + postsWithUpdates[ 0 ].originalPost + ); + } else { + successMessage = notice.success.messages.getBatchMessage( + postsWithUpdates.map( ( post ) => post.originalPost ) + ); + } + createSuccessNotice( successMessage, { + type: notice.success.type ?? 'snackbar', + id: notice.success.id, + } ); + callbacks.onActionPerformed?.( + postsWithUpdates.map( ( post ) => post.originalPost ) + ); + } else { + const errorMessages = getErrorMessagesFromPromises( allSettledResults ); + let errorMessage = ''; + if ( allSettledResults.length === 1 ) { + errorMessage = notice.error.messages.getMessage( errorMessages ); + } else { + errorMessage = + notice.error.messages.getBatchMessage( errorMessages ); + } + + createErrorNotice( errorMessage, { + type: notice.error.type ?? 'snackbar', + id: notice.error.id, + } ); + callbacks.onActionError?.(); + } +}; diff --git a/packages/fields/src/types.ts b/packages/fields/src/types.ts index 664c2dd417201c..a5ed9596b07dfd 100644 --- a/packages/fields/src/types.ts +++ b/packages/fields/src/types.ts @@ -54,6 +54,7 @@ export interface TemplatePart extends CommonPost { has_theme_file: boolean; id: string; area: string; + plugin?: string; } export interface Pattern extends CommonPost { diff --git a/packages/fields/src/wordpress-editor.d.ts b/packages/fields/src/wordpress-editor.d.ts deleted file mode 100644 index 915dacd5f05a9c..00000000000000 --- a/packages/fields/src/wordpress-editor.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@wordpress/editor'; diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json index c55be59acf40f0..69dbd076d05747 100644 --- a/packages/fields/tsconfig.json +++ b/packages/fields/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": false }, "references": [ + { "path": "../api-fetch" }, { "path": "../components" }, { "path": "../compose" }, { "path": "../data" }, @@ -24,6 +25,5 @@ { "path": "../hooks" }, { "path": "../html-entities" } ], - "include": [ "src" ], - "exclude": [ "@wordpress/editor" ] + "include": [ "src" ] } diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 0e162b64513d26..060e061b5c2843 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### New Features + +- added new `doActionAsync` and `applyFiltersAsync` functions to run hooks in async mode ([#64204](https://github.com/WordPress/gutenberg/pull/64204)). + ## 4.8.0 (2024-09-19) ## 4.7.0 (2024-09-05) diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 3e9897c79952cd..f80d2e63af37ba 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -41,7 +41,9 @@ One notable difference between the JS and PHP hooks API is that in the JS versio - `removeAllActions( 'hookName' )` - `removeAllFilters( 'hookName' )` - `doAction( 'hookName', arg1, arg2, moreArgs, finalArg )` +- `doActionAsync( 'hookName', arg1, arg2, moreArgs, finalArg )` - `applyFilters( 'hookName', content, arg1, arg2, moreArgs, finalArg )` +- `applyFiltersAsync( 'hookName', content, arg1, arg2, moreArgs, finalArg )` - `doingAction( 'hookName' )` - `doingFilter( 'hookName' )` - `didAction( 'hookName' )` diff --git a/packages/hooks/src/createCurrentHook.js b/packages/hooks/src/createCurrentHook.js index 634901fe55f63a..3ada0322496004 100644 --- a/packages/hooks/src/createCurrentHook.js +++ b/packages/hooks/src/createCurrentHook.js @@ -11,11 +11,8 @@ function createCurrentHook( hooks, storeKey ) { return function currentHook() { const hooksStore = hooks[ storeKey ]; - - return ( - hooksStore.__current[ hooksStore.__current.length - 1 ]?.name ?? - null - ); + const currentArray = Array.from( hooksStore.__current ); + return currentArray.at( -1 )?.name ?? null; }; } diff --git a/packages/hooks/src/createDoingHook.js b/packages/hooks/src/createDoingHook.js index 652ab06b4ba728..9fccf38171f332 100644 --- a/packages/hooks/src/createDoingHook.js +++ b/packages/hooks/src/createDoingHook.js @@ -24,13 +24,13 @@ function createDoingHook( hooks, storeKey ) { // If the hookName was not passed, check for any current hook. if ( 'undefined' === typeof hookName ) { - return 'undefined' !== typeof hooksStore.__current[ 0 ]; + return hooksStore.__current.size > 0; } - // Return the __current hook. - return hooksStore.__current[ 0 ] - ? hookName === hooksStore.__current[ 0 ].name - : false; + // Find if the `hookName` hook is in `__current`. + return Array.from( hooksStore.__current ).some( + ( hook ) => hook.name === hookName + ); }; } diff --git a/packages/hooks/src/createHooks.js b/packages/hooks/src/createHooks.js index 361383a3a97fc9..1f9b1a8206b020 100644 --- a/packages/hooks/src/createHooks.js +++ b/packages/hooks/src/createHooks.js @@ -20,11 +20,11 @@ export class _Hooks { constructor() { /** @type {import('.').Store} actions */ this.actions = Object.create( null ); - this.actions.__current = []; + this.actions.__current = new Set(); /** @type {import('.').Store} filters */ this.filters = Object.create( null ); - this.filters.__current = []; + this.filters.__current = new Set(); this.addAction = createAddHook( this, 'actions' ); this.addFilter = createAddHook( this, 'filters' ); @@ -34,8 +34,10 @@ export class _Hooks { this.hasFilter = createHasHook( this, 'filters' ); this.removeAllActions = createRemoveHook( this, 'actions', true ); this.removeAllFilters = createRemoveHook( this, 'filters', true ); - this.doAction = createRunHook( this, 'actions' ); - this.applyFilters = createRunHook( this, 'filters', true ); + this.doAction = createRunHook( this, 'actions', false, false ); + this.doActionAsync = createRunHook( this, 'actions', false, true ); + this.applyFilters = createRunHook( this, 'filters', true, false ); + this.applyFiltersAsync = createRunHook( this, 'filters', true, true ); this.currentAction = createCurrentHook( this, 'actions' ); this.currentFilter = createCurrentHook( this, 'filters' ); this.doingAction = createDoingHook( this, 'actions' ); diff --git a/packages/hooks/src/createRunHook.js b/packages/hooks/src/createRunHook.js index c2bf6fd187ce08..f2a56dbdc0d717 100644 --- a/packages/hooks/src/createRunHook.js +++ b/packages/hooks/src/createRunHook.js @@ -3,15 +3,15 @@ * registered to a hook of the specified type, optionally returning the final * value of the call chain. * - * @param {import('.').Hooks} hooks Hooks instance. + * @param {import('.').Hooks} hooks Hooks instance. * @param {import('.').StoreKey} storeKey - * @param {boolean} [returnFirstArg=false] Whether each hook callback is expected to - * return its first argument. + * @param {boolean} returnFirstArg Whether each hook callback is expected to return its first argument. + * @param {boolean} async Whether the hook callback should be run asynchronously * * @return {(hookName:string, ...args: unknown[]) => undefined|unknown} Function that runs hook callbacks. */ -function createRunHook( hooks, storeKey, returnFirstArg = false ) { - return function runHooks( hookName, ...args ) { +function createRunHook( hooks, storeKey, returnFirstArg, async ) { + return function runHook( hookName, ...args ) { const hooksStore = hooks[ storeKey ]; if ( ! hooksStore[ hookName ] ) { @@ -42,26 +42,43 @@ function createRunHook( hooks, storeKey, returnFirstArg = false ) { currentIndex: 0, }; - hooksStore.__current.push( hookInfo ); - - while ( hookInfo.currentIndex < handlers.length ) { - const handler = handlers[ hookInfo.currentIndex ]; - - const result = handler.callback.apply( null, args ); - if ( returnFirstArg ) { - args[ 0 ] = result; + async function asyncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = await handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); } - - hookInfo.currentIndex++; } - hooksStore.__current.pop(); - - if ( returnFirstArg ) { - return args[ 0 ]; + function syncRunner() { + try { + hooksStore.__current.add( hookInfo ); + let result = returnFirstArg ? args[ 0 ] : undefined; + while ( hookInfo.currentIndex < handlers.length ) { + const handler = handlers[ hookInfo.currentIndex ]; + result = handler.callback.apply( null, args ); + if ( returnFirstArg ) { + args[ 0 ] = result; + } + hookInfo.currentIndex++; + } + return returnFirstArg ? result : undefined; + } finally { + hooksStore.__current.delete( hookInfo ); + } } - return undefined; + return ( async ? asyncRunner : syncRunner )(); }; } diff --git a/packages/hooks/src/index.js b/packages/hooks/src/index.js index 653a9537145d91..1d13397e406c6b 100644 --- a/packages/hooks/src/index.js +++ b/packages/hooks/src/index.js @@ -25,7 +25,7 @@ import createHooks from './createHooks'; */ /** - * @typedef {Record & {__current: Current[]}} Store + * @typedef {Record & {__current: Set}} Store */ /** @@ -48,7 +48,9 @@ const { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -70,7 +72,9 @@ export { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, diff --git a/packages/hooks/src/test/index.test.js b/packages/hooks/src/test/index.test.js index 9b7eb3b8e0e223..5fdaf5fc7207a1 100644 --- a/packages/hooks/src/test/index.test.js +++ b/packages/hooks/src/test/index.test.js @@ -12,7 +12,9 @@ import { removeAllActions, removeAllFilters, doAction, + doActionAsync, applyFilters, + applyFiltersAsync, currentAction, currentFilter, doingAction, @@ -943,3 +945,151 @@ test( 'checking hasFilter with named callbacks and removeAllActions', () => { expect( hasFilter( 'test.filter', 'my_callback' ) ).toBe( false ); expect( hasFilter( 'test.filter', 'my_second_callback' ) ).toBe( false ); } ); + +describe( 'async filter', () => { + test( 'runs all registered handlers', async () => { + addFilter( 'test.async.filter', 'callback_plus1', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value + 1 ), 10 ) + ); + } ); + addFilter( 'test.async.filter', 'callback_times2', ( value ) => { + return new Promise( ( r ) => + setTimeout( () => r( value * 2 ), 10 ) + ); + } ); + + expect( await applyFiltersAsync( 'test.async.filter', 2 ) ).toBe( 6 ); + } ); + + test( 'aborts when handler throws an error', async () => { + const sqrt = jest.fn( async ( value ) => { + if ( value < 0 ) { + throw new Error( 'cannot pass negative value to sqrt' ); + } + return Math.sqrt( value ); + } ); + + const plus1 = jest.fn( async ( value ) => { + return value + 1; + } ); + + addFilter( 'test.async.filter', 'callback_sqrt', sqrt ); + addFilter( 'test.async.filter', 'callback_plus1', plus1 ); + + await expect( + applyFiltersAsync( 'test.async.filter', -1 ) + ).rejects.toThrow( 'cannot pass negative value to sqrt' ); + expect( sqrt ).toHaveBeenCalledTimes( 1 ); + expect( plus1 ).not.toHaveBeenCalled(); + } ); + + test( 'is correctly tracked by doingFilter and didFilter', async () => { + addFilter( 'test.async.filter', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter' ) ).toBe( true ); + return value; + } ); + + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 0 ); + await applyFiltersAsync( 'test.async.filter', 0 ); + expect( doingFilter( 'test.async.filter' ) ).toBe( false ); + expect( didFilter( 'test.async.filter' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple filters run at once', async () => { + addFilter( 'test.async.filter1', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + addFilter( 'test.async.filter2', 'callback_doing', async ( value ) => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingFilter( 'test.async.filter2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + return value; + } ); + + await Promise.all( [ + applyFiltersAsync( 'test.async.filter1', 0 ), + applyFiltersAsync( 'test.async.filter2', 0 ), + ] ); + } ); +} ); + +describe( 'async action', () => { + test( 'runs all registered handlers sequentially', async () => { + const outputs = []; + const action1 = async () => { + outputs.push( 1 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 2 ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await doActionAsync( 'test.async.action' ); + expect( outputs ).toEqual( [ 1, 2, 3, 4 ] ); + } ); + + test( 'aborts when handler throws an error', async () => { + const outputs = []; + const action1 = async () => { + throw new Error( 'aborting' ); + }; + + const action2 = async () => { + outputs.push( 3 ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + outputs.push( 4 ); + }; + + addAction( 'test.async.action', 'action1', action1 ); + addAction( 'test.async.action', 'action2', action2 ); + + await expect( doActionAsync( 'test.async.action' ) ).rejects.toThrow( + 'aborting' + ); + expect( outputs ).toEqual( [] ); + } ); + + test( 'is correctly tracked by doingAction and didAction', async () => { + addAction( 'test.async.action', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action' ) ).toBe( true ); + } ); + + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 0 ); + await doActionAsync( 'test.async.action', 0 ); + expect( doingAction( 'test.async.action' ) ).toBe( false ); + expect( didAction( 'test.async.action' ) ).toBe( 1 ); + } ); + + test( 'is correctly tracked when multiple actions run at once', async () => { + addAction( 'test.async.action1', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action1' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + addAction( 'test.async.action2', 'callback_doing', async () => { + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + expect( doingAction( 'test.async.action2' ) ).toBe( true ); + await new Promise( ( r ) => setTimeout( () => r(), 10 ) ); + } ); + + await Promise.all( [ + doActionAsync( 'test.async.action1', 0 ), + doActionAsync( 'test.async.action2', 0 ), + ] ); + } ); +} ); diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index be047e2181d4a5..ddf850dd116819 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -6,7 +6,10 @@ ### New Features +- Add new `envelope` icon. + - Add new `bell` and `bell-unread` icons. +- Add new `arrowUpLeft` and `arrowDownRight` icons. ## 10.7.0 (2024-09-05) diff --git a/packages/icons/src/icon/stories/index.story.js b/packages/icons/src/icon/stories/index.story.js index 8fda801f23884f..092434de43b4dc 100644 --- a/packages/icons/src/icon/stories/index.story.js +++ b/packages/icons/src/icon/stories/index.story.js @@ -47,7 +47,7 @@ const LibraryExample = () => { const filteredIcons = filter.length ? Object.fromEntries( Object.entries( availableIcons ).filter( ( [ name ] ) => - name.includes( filter ) + name.toLowerCase().includes( filter.toLowerCase() ) ) ) : availableIcons; diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index 9ab41bd3620279..586911ffc746b2 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -10,9 +10,11 @@ export { default as alignNone } from './library/align-none'; export { default as alignRight } from './library/align-right'; export { default as archive } from './library/archive'; export { default as arrowDown } from './library/arrow-down'; +export { default as arrowDownRight } from './library/arrow-down-right'; export { default as arrowLeft } from './library/arrow-left'; export { default as arrowRight } from './library/arrow-right'; export { default as arrowUp } from './library/arrow-up'; +export { default as arrowUpLeft } from './library/arrow-up-left'; export { default as atSymbol } from './library/at-symbol'; export { default as aspectRatio } from './library/aspect-ratio'; export { default as audio } from './library/audio'; @@ -79,6 +81,7 @@ export { default as drawerLeft } from './library/drawer-left'; export { default as drawerRight } from './library/drawer-right'; 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 file } from './library/file'; export { default as filter } from './library/filter'; diff --git a/packages/icons/src/library/arrow-down-right.js b/packages/icons/src/library/arrow-down-right.js new file mode 100644 index 00000000000000..3755b63873cefc --- /dev/null +++ b/packages/icons/src/library/arrow-down-right.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const arrowDownRight = ( + + + +); + +export default arrowDownRight; diff --git a/packages/icons/src/library/arrow-up-left.js b/packages/icons/src/library/arrow-up-left.js new file mode 100644 index 00000000000000..1b3686f6ec1e62 --- /dev/null +++ b/packages/icons/src/library/arrow-up-left.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const arrowUpLeft = ( + + + +); + +export default arrowUpLeft; diff --git a/packages/icons/src/library/envelope.js b/packages/icons/src/library/envelope.js new file mode 100644 index 00000000000000..45064b35785ec1 --- /dev/null +++ b/packages/icons/src/library/envelope.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const envelope = ( + + + +); + +export default envelope; diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md index 94b88e80886c90..efb52e59be2b5d 100644 --- a/packages/interactivity-router/README.md +++ b/packages/interactivity-router/README.md @@ -1,21 +1,32 @@ -# Interactivity Router +# `@wordpress/interactivity-router` -> **Note** -> This package is a extension of the API shared at [Proposal: The Interactivity API ā€“ A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. +The package `@wordpress/interactivity-router` enables loading content from other pages without a full page reload. Currently, the only supported mode is "region-based". Full "client-side navigation" is still in experimental phase. -This package defines an Interactivity API store with the `core/router` namespace, exposing state and actions like `navigate` and `prefetch` to handle client-side navigations. +The package defines an Interactivity API store with the `core/router` namespace, exposing state and 2 actions: `navigate` and `prefetch` to handle client-side navigation. + +The `@wordpress/interactivity-router` package was [introduced in WordPress Core in v6.5](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/). This means this package is already bundled in Core in any version of WordPress higher than v6.5. + +
## Usage -The package is intended to be imported dynamically in the `view.js` files of interactive blocks. +The package is intended to be imported dynamically in the `view.js` files of interactive blocks. This is done in in order to reduce the JS bundle size on the initial page load. ```js +/* view.js */ + import { store } from '@wordpress/interactivity'; -store( 'myblock', { +// This is how you would typically use the navigate() action in your block. +store( 'my-namespace/myblock', { actions: { - *navigate( e ) { + *goToPage( e ) { e.preventDefault(); + + // We import the package dynamically to reduce the initial JS bundle size. + // Async actions are defined as generators so the import() must be called with `yield`. const { actions } = yield import( '@wordpress/interactivity-router' ); @@ -25,52 +36,116 @@ store( 'myblock', { } ); ``` -## Frequently Asked Questions +Now, you can call `actions.navigate()` in your block's `view.js` file to navigate to a different page or e.g. pass it to a `data-wp-on--click` attribute. + +When loaded, this package [adds the following state and actions](https://github.com/WordPress/gutenberg/blob/ed7d78652526270b63976d7a970dba46a2bfcbb0/packages/interactivity-router/src/index.ts#L212) to the `core/router` store: + +```js +const { state, actions } = store( 'core/router', { + state: { + url: window.location.href, + navigation: { + hasStarted: false, + hasFinished: false, + texts: { + loading: '', + loaded: '', + }, + message: '', + }, + }, + actions: { + *navigate(href, options) {...}, + prefetch(url, options) {...}, + } +}) +``` + +
+ The core "Query Loop" block is using this package to provide the region-based navigation. +
+ +### Directives + +#### `data-wp-router-region` + +It defines a region that is updated on navigation. It requires a unique ID as the value and can only be used in root interactive elements, i.e., elements with `data-wp-interactive` that are not nested inside other elements with `data-wp-interactive`. + +Example: + +```html +
+ + +
+``` + +### Actions + +#### `navigate` + +Navigates to the specified page. -At this point, some of the questions you have about the Interactivity API may be: +This function normalizes the passed `href`, fetches the page HTML if needed, and updates any interactive regions whose contents have changed in the new page. It also creates a new entry in the browser session history. -### What is this? +**Params** -This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this. +```js +navigate( href: string, options: NavigateOptions = {} ) +``` -### Can I use it? +- `href`: The page `href`. +- `options`: Options object. + - `force`: If `true`, it forces re-fetching the URL. `navigate()` always caches the page, so if the page has been navigated to before, it will be used. Default is `false`. + - `html`: HTML string to be used instead of fetching the requested URL. + - `replace`: If `true`, it replaces the current entry in the browser session history. Default is `false`. + - `timeout`: Time until the navigation is aborted, in milliseconds. Default is `10000`. + - `loadingAnimation`: Whether an animation should be shown while navigating. Default to `true`. + - `screenReaderAnnouncement`: Whether a message for screen readers should be announced while navigating. Default to `true`. -You can test it, but it's still very experimental. +#### `prefetch` -### How do I get started? +Prefetches the page for the passed URL. The page is cached and can be used for navigation. -The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. +The function normalizes the URL and stores internally the fetch promise, to avoid triggering a second fetch for an ongoing request. -### Where can I ask questions? +**Params** -The [ā€œInteractivity APIā€ category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API. +```js +prefetch( url: string, options: PrefetchOptions = {} ) +``` -### Where can I share my feedback about the API? +- `url`: The page `url`. +- `options`: Options object. -The [ā€œInteractivity APIā€ category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API. + - `force`: If `true`, forces fetching the URL again. + - `html`: HTML string to be used instead of fetching the requested URL. + +### State + +`state.url` is a reactive property synchronized with the current URL. +Properties under `state.navigation` are meant for loading bar animations. ## Installation Install the module: ```bash -npm install @wordpress/interactivity --save +npm install @wordpress/interactivity-router --save ``` -_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ - -## Docs & Examples +This step is only required if you use the Interactivity API outside WordPress. -**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available: +Within WordPress, the package is already bundled in Core. To ensure it's enqueued, add `@wordpress/interactivity-router` to the dependency array of the script module. This process is often done automatically with tools like [`wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/). -- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks. -- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store. +Furthermore, this package assumes your code will run in an **ES2015+** environment. If you're using an environment with limited or no support for such language features and APIs, you should include the polyfill shipped in [`@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code. -Here you have some more resources to learn/read more about the Interactivity API: +## License -- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)** -- [Proposal: The Interactivity API ā€“ A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) -- Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA)) -- [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo +Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license. -

Code is Poetry.

+

Code is Poetry.

diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts index 3bd44c7aebd71f..b2e8e2d4395dcd 100644 --- a/packages/interactivity-router/src/index.ts +++ b/packages/interactivity-router/src/index.ts @@ -221,11 +221,6 @@ interface Store { navigation: { hasStarted: boolean; hasFinished: boolean; - message: string; - texts?: { - loading?: string; - loaded?: string; - }; }; }; actions: { @@ -240,7 +235,6 @@ export const { state, actions } = store< Store >( 'core/router', { navigation: { hasStarted: false, hasFinished: false, - message: '', }, }, actions: { @@ -403,10 +397,16 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) { } catch {} } else { // Fallback to localized strings from Interactivity API state. + // @todo This block is for Core < 6.7.0. Remove when support is dropped. + + // @ts-expect-error if ( state.navigation.texts?.loading ) { + // @ts-expect-error navigationTexts.loading = state.navigation.texts.loading; } + // @ts-expect-error if ( state.navigation.texts?.loaded ) { + // @ts-expect-error navigationTexts.loaded = state.navigation.texts.loaded; } } @@ -414,19 +414,11 @@ function a11ySpeak( messageKey: keyof typeof navigationTexts ) { const message = navigationTexts[ messageKey ]; - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - import( '@wordpress/a11y' ).then( - ( { speak } ) => speak( message ), - // Ignore failures to load the a11y module. - () => {} - ); - } else { - state.navigation.message = - // Announce that the page has been loaded. If the message is the - // same, we use a no-break space similar to the @wordpress/a11y - // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 - message + ( state.navigation.message === message ? '\u00A0' : '' ); - } + import( '@wordpress/a11y' ).then( + ( { speak } ) => speak( message ), + // Ignore failures to load the a11y module. + () => {} + ); } // Add click and prefetch to all links. diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 6989bcdc0c802c..42f311973709dd 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -6,6 +6,7 @@ ### Enhancements +- Improve TypeScript support for generators ([#64577](https://github.com/WordPress/gutenberg/pull/64577)). - Refactor internal context proxies implementation ([#64713](https://github.com/WordPress/gutenberg/pull/64713)). ### Bug Fixes diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx index cde39d830499a2..340880954683da 100644 --- a/packages/interactivity/src/directives.tsx +++ b/packages/interactivity/src/directives.tsx @@ -142,14 +142,19 @@ export default () => { const defaultEntry = context.find( ( { suffix } ) => suffix === 'default' ); - const inheritedValue = useContext( inheritedContext ); + const { client: inheritedClient, server: inheritedServer } = + useContext( inheritedContext ); const ns = defaultEntry!.namespace; - const currentValue = useRef( proxifyState( ns, {} ) ); + const client = useRef( proxifyState( ns, {} ) ); + const server = useRef( proxifyState( ns, {}, { readOnly: true } ) ); // No change should be made if `defaultEntry` does not exist. const contextStack = useMemo( () => { - const result = { ...inheritedValue }; + const result = { + client: { ...inheritedClient }, + server: { ...inheritedServer }, + }; if ( defaultEntry ) { const { namespace, value } = defaultEntry; // Check that the value is a JSON object. Send a console warning if not. @@ -159,17 +164,22 @@ export default () => { ); } deepMerge( - currentValue.current, + client.current, deepClone( value ) as object, false ); - result[ namespace ] = proxifyContext( - currentValue.current, - inheritedValue[ namespace ] + deepMerge( server.current, deepClone( value ) as object ); + result.client[ namespace ] = proxifyContext( + client.current, + inheritedClient[ namespace ] + ); + result.server[ namespace ] = proxifyContext( + server.current, + inheritedServer[ namespace ] ); } return result; - }, [ defaultEntry, inheritedValue ] ); + }, [ defaultEntry, inheritedClient, inheritedServer ] ); return createElement( Provider, { value: contextStack }, children ); }, @@ -563,17 +573,24 @@ export default () => { suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); const itemContext = proxifyContext( proxifyState( namespace, {} ), - inheritedValue[ namespace ] + inheritedValue.client[ namespace ] ); const mergedContext = { - ...inheritedValue, - [ namespace ]: itemContext, + client: { + ...inheritedValue.client, + [ namespace ]: itemContext, + }, + server: { ...inheritedValue.server }, }; // Set the item after proxifying the context. - mergedContext[ namespace ][ itemProp ] = item; + mergedContext.client[ namespace ][ itemProp ] = item; - const scope = { ...getScope(), context: mergedContext }; + const scope = { + ...getScope(), + context: mergedContext.client, + serverContext: mergedContext.server, + }; const key = eachKey ? getEvaluate( { scope } )( eachKey[ 0 ] ) : item; diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index 215da8afef9b5b..6b55ec014aa799 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -93,7 +93,7 @@ interface DirectivesProps { } // Main context. -const context = createContext< any >( {} ); +const context = createContext< any >( { client: {}, server: {} } ); // WordPress Directives. const directiveCallbacks: Record< string, DirectiveCallback > = {}; @@ -190,9 +190,13 @@ const resolve = ( path: string, namespace: string ) => { } let resolvedStore = stores.get( namespace ); if ( typeof resolvedStore === 'undefined' ) { - resolvedStore = store( namespace, undefined, { - lock: universalUnlock, - } ); + resolvedStore = store( + namespace, + {}, + { + lock: universalUnlock, + } + ); } const current = { ...resolvedStore, @@ -253,7 +257,9 @@ const Directives = ( { // element ref, state and props. const scope = useRef< Scope >( {} as Scope ).current; scope.evaluate = useCallback( getEvaluate( { scope } ), [] ); - scope.context = useContext( context ); + const { client, server } = useContext( context ); + scope.context = client; + scope.serverContext = server; /* eslint-disable react-hooks/rules-of-hooks */ scope.ref = previousScope?.ref || useRef( null ); /* eslint-enable react-hooks/rules-of-hooks */ diff --git a/packages/interactivity/src/index.ts b/packages/interactivity/src/index.ts index 336c2a97226db7..9d013e4e744ed5 100644 --- a/packages/interactivity/src/index.ts +++ b/packages/interactivity/src/index.ts @@ -16,8 +16,8 @@ import { getNamespace } from './namespaces'; import { parseServerData, populateServerData } from './store'; import { proxifyState } from './proxies'; -export { store, getConfig } from './store'; -export { getContext, getElement } from './scopes'; +export { store, getConfig, getServerState } from './store'; +export { getContext, getServerContext, getElement } from './scopes'; export { withScope, useWatch, diff --git a/packages/interactivity/src/proxies/state.ts b/packages/interactivity/src/proxies/state.ts index ec49c4b27c4adb..c91d8f6ab90a5b 100644 --- a/packages/interactivity/src/proxies/state.ts +++ b/packages/interactivity/src/proxies/state.ts @@ -46,6 +46,8 @@ const proxyToProps: WeakMap< export const hasPropSignal = ( proxy: object, key: string ) => proxyToProps.has( proxy ) && proxyToProps.get( proxy )!.has( key ); +const readOnlyProxies = new WeakSet(); + /** * Returns the {@link PropSignal | `PropSignal`} instance associated with the * specified prop in the passed proxy. @@ -77,8 +79,11 @@ const getPropSignal = ( if ( get ) { prop.setGetter( get ); } else { + const readOnly = readOnlyProxies.has( proxy ); prop.setValue( - shouldProxy( value ) ? proxifyState( ns, value ) : value + shouldProxy( value ) + ? proxifyState( ns, value, { readOnly } ) + : value ); } } @@ -148,6 +153,9 @@ const stateHandlers: ProxyHandler< object > = { value: unknown, receiver: object ): boolean { + if ( readOnlyProxies.has( receiver ) ) { + return false; + } setNamespace( getNamespaceFromProxy( receiver ) ); try { return Reflect.set( target, key, value, receiver ); @@ -161,6 +169,10 @@ const stateHandlers: ProxyHandler< object > = { key: string, desc: PropertyDescriptor ): boolean { + if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) { + return false; + } + const isNew = ! ( key in target ); const result = Reflect.defineProperty( target, key, desc ); @@ -199,6 +211,10 @@ const stateHandlers: ProxyHandler< object > = { }, deleteProperty( target: object, key: string ): boolean { + if ( readOnlyProxies.has( getProxyFromObject( target )! ) ) { + return false; + } + const result = Reflect.deleteProperty( target, key ); if ( result ) { @@ -230,8 +246,10 @@ const stateHandlers: ProxyHandler< object > = { * Returns the proxy associated with the given state object, creating it if it * does not exist. * - * @param namespace The namespace that will be associated to this proxy. - * @param obj The object to proxify. + * @param namespace The namespace that will be associated to this proxy. + * @param obj The object to proxify. + * @param options Options. + * @param options.readOnly Read-only. * * @throws Error if the object cannot be proxified. Use {@link shouldProxy} to * check if a proxy can be created for a specific object. @@ -240,8 +258,15 @@ const stateHandlers: ProxyHandler< object > = { */ export const proxifyState = < T extends object >( namespace: string, - obj: T -): T => createProxy( namespace, obj, stateHandlers ) as T; + obj: T, + options?: { readOnly?: boolean } +): T => { + const proxy = createProxy( namespace, obj, stateHandlers ) as T; + if ( options?.readOnly ) { + readOnlyProxies.add( proxy ); + } + return proxy; +}; /** * Reads the value of the specified property without subscribing to it. diff --git a/packages/interactivity/src/proxies/test/state-proxy.ts b/packages/interactivity/src/proxies/test/state-proxy.ts index 92500189fc8309..4b0d2b0a708c3a 100644 --- a/packages/interactivity/src/proxies/test/state-proxy.ts +++ b/packages/interactivity/src/proxies/test/state-proxy.ts @@ -9,7 +9,7 @@ import { effect } from '@preact/signals'; /** * Internal dependencies */ -import { proxifyState, peek } from '../'; +import { proxifyState, peek, deepMerge } from '../'; import { setScope, resetScope, getContext, getElement } from '../../scopes'; import { setNamespace, resetNamespace } from '../../namespaces'; @@ -1265,5 +1265,202 @@ describe( 'Interactivity API', () => { expect( x ).toBe( undefined ); } ); } ); + + describe( 'read-only', () => { + it( "should not allow modifying a prop's value", () => { + const readOnlyState = proxifyState( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + readOnlyState.prop = 'new value'; + } ).toThrow(); + expect( () => { + readOnlyState.nested.prop = 'new value'; + } ).toThrow(); + } ); + + it( 'should not allow modifying a prop descriptor', () => { + const readOnlyState = proxifyState( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + Object.defineProperty( readOnlyState, 'prop', { + get: () => 'value from getter', + writable: true, + enumerable: false, + } ); + } ).toThrow(); + expect( () => { + Object.defineProperty( readOnlyState.nested, 'prop', { + get: () => 'value from getter', + writable: true, + enumerable: false, + } ); + } ).toThrow(); + } ); + + it( 'should not allow adding new props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + readOnlyState.newProp = 'value'; + } ).toThrow(); + expect( () => { + readOnlyState.nested.newProp = 'value'; + } ).toThrow(); + } ); + + it( 'should not allow removing props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { prop: 'value', nested: { prop: 'value' } }, + { readOnly: true } + ); + + expect( () => { + delete readOnlyState.prop; + } ).toThrow(); + expect( () => { + delete readOnlyState.nested.prop; + } ).toThrow(); + } ); + + it( 'should not allow adding items to an array', () => { + const readOnlyState = proxifyState( + 'test', + { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } }, + { readOnly: true } + ); + + expect( () => readOnlyState.array.push( 4 ) ).toThrow(); + expect( () => readOnlyState.nested.array.push( 4 ) ).toThrow(); + } ); + + it( 'should not allow removing items from an array', () => { + const readOnlyState = proxifyState( + 'test', + { array: [ 1, 2, 3 ], nested: { array: [ 1, 2, 3 ] } }, + { readOnly: true } + ); + + expect( () => readOnlyState.array.pop() ).toThrow(); + expect( () => readOnlyState.nested.array.pop() ).toThrow(); + } ); + + it( 'should allow subscribing to prop changes', () => { + const readOnlyState = proxifyState( + 'test', + { + prop: 'value', + nested: { prop: 'value' }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.prop ); + const spy2 = jest.fn( () => readOnlyState.nested.prop ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + + deepMerge( readOnlyState, { prop: 'new value' } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'new value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + + deepMerge( readOnlyState, { nested: { prop: 'new value' } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 'new value' ); + expect( spy2 ).toHaveLastReturnedWith( 'new value' ); + } ); + + it( 'should allow subscribing to new props', () => { + const readOnlyState = proxifyState< any >( + 'test', + { + prop: 'value', + nested: { prop: 'value' }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.newProp ); + const spy2 = jest.fn( () => readOnlyState.nested.newProp ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( undefined ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + + deepMerge( readOnlyState, { newProp: 'value' } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + + deepMerge( readOnlyState, { nested: { newProp: 'value' } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 'value' ); + expect( spy2 ).toHaveLastReturnedWith( 'value' ); + } ); + + it( 'should allow subscribing to array changes', () => { + const readOnlyState = proxifyState< any >( + 'test', + { + array: [ 1, 2, 3 ], + nested: { array: [ 1, 2, 3 ] }, + }, + { readOnly: true } + ); + + const spy1 = jest.fn( () => readOnlyState.array[ 0 ] ); + const spy2 = jest.fn( () => readOnlyState.nested.array[ 0 ] ); + + effect( spy1 ); + effect( spy2 ); + expect( spy1 ).toHaveBeenCalledTimes( 1 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 1 ); + expect( spy2 ).toHaveLastReturnedWith( 1 ); + + deepMerge( readOnlyState, { array: [ 4, 5, 6 ] } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 1 ); + expect( spy1 ).toHaveLastReturnedWith( 4 ); + expect( spy2 ).toHaveLastReturnedWith( 1 ); + + deepMerge( readOnlyState, { nested: { array: [] } } ); + + expect( spy1 ).toHaveBeenCalledTimes( 2 ); + expect( spy2 ).toHaveBeenCalledTimes( 2 ); + expect( spy1 ).toHaveLastReturnedWith( 4 ); + expect( spy2 ).toHaveLastReturnedWith( undefined ); + } ); + } ); } ); } ); diff --git a/packages/interactivity/src/scopes.ts b/packages/interactivity/src/scopes.ts index 2e78755ec4bbe6..722305f6bee112 100644 --- a/packages/interactivity/src/scopes.ts +++ b/packages/interactivity/src/scopes.ts @@ -12,6 +12,7 @@ import type { Evaluate } from './hooks'; export interface Scope { evaluate: Evaluate; context: object; + serverContext: object; ref: RefObject< HTMLElement >; attributes: createElement.JSX.HTMLAttributes; } @@ -96,3 +97,46 @@ export const getElement = () => { attributes: deepImmutable( attributes ), } ); }; + +/** + * Retrieves the part of the inherited context defined and updated from the + * server. + * + * The object returned is read-only, and includes the context defined in PHP + * with `wp_interactivity_data_wp_context()`, including the corresponding + * inherited properties. When `actions.navigate()` is called, this object is + * updated to reflect the changes in the new visited page, without affecting the + * context returned by `getContext()`. Directives can subscribe to those changes + * to update the context if needed. + * + * @example + * ```js + * store('...', { + * callbacks: { + * updateServerContext() { + * const context = getContext(); + * const serverContext = getServerContext(); + * // Override some property with the new value that came from the server. + * context.overridableProp = serverContext.overridableProp; + * }, + * }, + * }); + * ``` + * + * @param namespace Store namespace. By default, the namespace where the calling + * function exists is used. + * @return The server context content. + */ +export const getServerContext = < T extends object >( + namespace?: string +): T => { + const scope = getScope(); + if ( globalThis.SCRIPT_DEBUG ) { + if ( ! scope ) { + throw Error( + 'Cannot call `getServerContext()` when there is no scope. If you are using an async function, please consider using a generator instead. If you are using some sort of async callbacks, like `setTimeout`, please wrap the callback with `withScope(callback)`.' + ); + } + } + return scope.serverContext[ namespace || getNamespace() ]; +}; diff --git a/packages/interactivity/src/store.ts b/packages/interactivity/src/store.ts index c74764b902e194..b147e0f61163bf 100644 --- a/packages/interactivity/src/store.ts +++ b/packages/interactivity/src/store.ts @@ -12,6 +12,7 @@ export const stores = new Map(); const rawStores = new Map(); const storeLocks = new Map(); const storeConfigs = new Map(); +const serverStates = new Map(); /** * Get the defined config for the store with the passed namespace. @@ -22,6 +23,39 @@ const storeConfigs = new Map(); export const getConfig = ( namespace?: string ) => storeConfigs.get( namespace || getNamespace() ) || {}; +/** + * Get the part of the state defined and updated from the server. + * + * The object returned is read-only, and includes the state defined in PHP with + * `wp_interactivity_state()`. When using `actions.navigate()`, this object is + * updated to reflect the changes in its properites, without affecting the state + * returned by `store()`. Directives can subscribe to those changes to update + * the state if needed. + * + * @example + * ```js + * const { state } = store('myStore', { + * callbacks: { + * updateServerState() { + * const serverState = getServerState(); + * // Override some property with the new value that came from the server. + * state.overridableProp = serverState.overridableProp; + * }, + * }, + * }); + * ``` + * + * @param namespace Store's namespace from which to retrieve the server state. + * @return The server state for the given namespace. + */ +export const getServerState = ( namespace?: string ) => { + const ns = namespace || getNamespace(); + if ( ! serverStates.has( ns ) ) { + serverStates.set( ns, proxifyState( ns, {}, { readOnly: true } ) ); + } + return serverStates.get( ns ); +}; + interface StoreOptions { /** * Property to block/unblock private store namespaces. @@ -50,6 +84,42 @@ interface StoreOptions { lock?: boolean | string; } +type Prettify< T > = { [ K in keyof T ]: T[ K ] } & {}; +type DeepPartial< T > = T extends object + ? { [ P in keyof T ]?: DeepPartial< T[ P ] > } + : T; +type DeepPartialState< T extends { state: object } > = Omit< T, 'state' > & { + state?: DeepPartial< T[ 'state' ] >; +}; +type ConvertGeneratorToPromise< T > = T extends ( + ...args: infer A +) => Generator< any, infer R, any > + ? ( ...args: A ) => Promise< R > + : never; +type ConvertGeneratorsToPromises< T > = { + [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any + ? ConvertGeneratorToPromise< T[ K ] > extends never + ? T[ K ] + : ConvertGeneratorToPromise< T[ K ] > + : T[ K ] extends object + ? Prettify< ConvertGeneratorsToPromises< T[ K ] > > + : T[ K ]; +}; +type ConvertPromiseToGenerator< T > = T extends ( + ...args: infer A +) => Promise< infer R > + ? ( ...args: A ) => Generator< any, R, any > + : never; +type ConvertPromisesToGenerators< T > = { + [ K in keyof T ]: T[ K ] extends ( ...args: any[] ) => any + ? ConvertPromiseToGenerator< T[ K ] > extends never + ? T[ K ] + : ConvertPromiseToGenerator< T[ K ] > + : T[ K ] extends object + ? Prettify< ConvertPromisesToGenerators< T[ K ] > > + : T[ K ]; +}; + export const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; @@ -98,17 +168,34 @@ export const universalUnlock = * * @return A reference to the namespace content. */ -export function store< S extends object = {} >( + +// Overload for when the types are inferred. +export function store< T extends object >( + namespace: string, + storePart: T, + options?: StoreOptions +): Prettify< ConvertGeneratorsToPromises< T > >; + +// Overload for when types are passed via generics and they contain state. +export function store< T extends { state: object } >( + namespace: string, + storePart: ConvertPromisesToGenerators< DeepPartialState< T > >, + options?: StoreOptions +): Prettify< ConvertGeneratorsToPromises< T > >; + +// Overload for when types are passed via generics and they don't contain state. +export function store< T extends object >( namespace: string, - storePart?: S, + storePart: ConvertPromisesToGenerators< T >, options?: StoreOptions -): S; +): Prettify< ConvertGeneratorsToPromises< T > >; +// Overload for when types are divided into multiple parts. export function store< T extends object >( namespace: string, - storePart?: T, + storePart: ConvertPromisesToGenerators< DeepPartial< T > >, options?: StoreOptions -): T; +): Prettify< ConvertGeneratorsToPromises< T > >; export function store( namespace: string, @@ -187,6 +274,7 @@ export const populateServerData = ( data?: { Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => { const st = store< any >( namespace, {}, { lock: universalUnlock } ); deepMerge( st.state, state, false ); + deepMerge( getServerState( namespace ), state ); } ); } if ( isPlainObject( data?.config ) ) { diff --git a/packages/interactivity/src/test/store.ts b/packages/interactivity/src/test/store.ts new file mode 100644 index 00000000000000..1092001db03143 --- /dev/null +++ b/packages/interactivity/src/test/store.ts @@ -0,0 +1,286 @@ +/** + * Internal dependencies + */ +import { store } from '../store'; + +describe( 'Interactivity API', () => { + describe( 'store', () => { + it( 'dummy test', () => { + expect( true ).toBe( true ); + } ); + + describe( 'types', () => { + describe( 'the whole store can be inferred', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + const myStore = store( 'test', { + state: { + clientValue: 1, + get derived(): number { + return myStore.state.clientValue; + }, + }, + actions: { + sync( n: number ) { + return n; + }, + *async( n: number ) { + const n1: number = + yield myStore.actions.sync( n ); + return myStore.state.derived + n1 + n; + }, + }, + } ); + + myStore.state.clientValue satisfies number; + myStore.state.derived satisfies number; + + // @ts-expect-error + myStore.state.nonExistent satisfies number; + myStore.actions.sync( 1 ) satisfies number; + myStore.actions.async( 1 ) satisfies Promise< number >; + ( await myStore.actions.async( 1 ) ) satisfies number; + + // @ts-expect-error + myStore.actions.nonExistent() satisfies {}; + }; + } ); + + describe( 'the whole store can be manually typed', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + interface Store { + state: { + clientValue: number; + serverValue: number; + readonly derived: number; + }; + actions: { + sync: ( n: number ) => number; + async: ( n: number ) => Promise< number >; + }; + } + + const myStore = store< Store >( 'test', { + state: { + clientValue: 1, + // @ts-expect-error + nonExistent: 2, + get derived(): number { + return myStore.state.serverValue; + }, + }, + actions: { + sync( n ) { + return n; + }, + *async( n ): Generator< unknown, number, unknown > { + const n1 = myStore.actions.sync( n ); + return myStore.state.derived + n1 + n; + }, + }, + } ); + + myStore.state.clientValue satisfies number; + myStore.state.serverValue satisfies number; + myStore.state.derived satisfies number; + // @ts-expect-error + myStore.state.nonExistent satisfies number; + myStore.actions.sync( 1 ) satisfies number; + myStore.actions.async( 1 ) satisfies Promise< number >; + ( await myStore.actions.async( 1 ) ) satisfies number; + // @ts-expect-error + myStore.actions.nonExistent(); + }; + } ); + + describe( 'the server state can be typed and the rest inferred', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + type ServerStore = { + state: { + serverValue: number; + }; + }; + + const clientStore = { + state: { + clientValue: 1, + get derived(): number { + return myStore.state.serverValue; + }, + }, + actions: { + sync( n: number ) { + return n; + }, + *async( + n: number + ): Generator< unknown, number, number > { + const n1: number = + yield myStore.actions.sync( n ); + return myStore.state.derived + n1 + n; + }, + }, + }; + + type Store = ServerStore & typeof clientStore; + + const myStore = store< Store >( 'test', clientStore ); + + myStore.state.clientValue satisfies number; + myStore.state.serverValue satisfies number; + myStore.state.derived satisfies number; + // @ts-expect-error + myStore.state.nonExistent satisfies number; + myStore.actions.sync( 1 ) satisfies number; + myStore.actions.async( 1 ) satisfies Promise< number >; + ( await myStore.actions.async( 1 ) ) satisfies number; + // @ts-expect-error + myStore.actions.nonExistent(); + }; + } ); + + describe( 'the state can be casted and the rest inferred', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + type State = { + clientValue: number; + serverValue: number; + derived: number; + }; + + const myStore = store( 'test', { + state: { + clientValue: 1, + get derived(): number { + return myStore.state.serverValue; + }, + } as State, + actions: { + sync( n: number ) { + return n; + }, + *async( + n: number + ): Generator< unknown, number, number > { + const n1: number = + yield myStore.actions.sync( n ); + return myStore.state.derived + n1 + n; + }, + }, + } ); + + myStore.state.clientValue satisfies number; + myStore.state.serverValue satisfies number; + myStore.state.derived satisfies number; + // @ts-expect-error + myStore.state.nonExistent satisfies number; + myStore.actions.sync( 1 ) satisfies number; + myStore.actions.async( 1 ) satisfies Promise< number >; + ( await myStore.actions.async( 1 ) ) satisfies number; + // @ts-expect-error + myStore.actions.nonExistent() satisfies {}; + }; + } ); + + describe( 'the whole store can be manually typed even if doesnt contain state', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + interface Store { + actions: { + sync: ( n: number ) => number; + async: ( n: number ) => Promise< number >; + }; + callbacks: { + existent: number; + }; + } + + const myStore = store< Store >( 'test', { + actions: { + sync( n ) { + return n; + }, + *async( n ): Generator< unknown, number, number > { + const n1: number = + yield myStore.actions.sync( n ); + return n1 + n; + }, + }, + callbacks: { + existent: 1, + // @ts-expect-error + nonExistent: 1, + }, + } ); + + // @ts-expect-error + myStore.state.nonExistent satisfies number; + myStore.actions.sync( 1 ) satisfies number; + myStore.actions.async( 1 ) satisfies Promise< number >; + ( await myStore.actions.async( 1 ) ) satisfies number; + myStore.callbacks.existent satisfies number; + // @ts-expect-error + myStore.callbacks.nonExistent satisfies number; + // @ts-expect-error + myStore.actions.nonExistent() satisfies {}; + }; + } ); + + describe( 'the store can be divided into multiple parts', () => { + // eslint-disable-next-line no-unused-expressions + async () => { + type ServerState = { + state: { + serverValue: number; + }; + }; + + const firstStorePart = { + state: { + clientValue1: 1, + }, + actions: { + incrementValue1( n = 1 ) { + myStore.state.clientValue1 += n; + }, + }, + }; + + type FirstStorePart = typeof firstStorePart; + + const secondStorePart = { + state: { + clientValue2: 'test', + }, + actions: { + *asyncAction() { + return ( + myStore.state.clientValue1 + + myStore.state.serverValue + ); + }, + }, + }; + + type Store = ServerState & + FirstStorePart & + typeof secondStorePart; + + const myStore = store< Store >( 'test', firstStorePart ); + store( 'test', secondStorePart ); + + myStore.state.clientValue1 satisfies number; + myStore.state.clientValue2 satisfies string; + myStore.actions.incrementValue1( 1 ); + myStore.actions.asyncAction() satisfies Promise< number >; + ( await myStore.actions.asyncAction() ) satisfies number; + + // @ts-expect-error + myStore.state.nonExistent satisfies {}; + }; + } ); + } ); + } ); +} ); diff --git a/packages/interactivity/tsconfig.test.json b/packages/interactivity/tsconfig.test.json new file mode 100644 index 00000000000000..6a90abc2ba2210 --- /dev/null +++ b/packages/interactivity/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true, + "emitDeclarationOnly": false, + "types": [ "jest" ] + }, + "references": [ { "path": "./tsconfig.json" } ], + "files": [ "src/test/store.ts" ], + "exclude": [] +} diff --git a/packages/interface/src/components/complementary-area-toggle/index.js b/packages/interface/src/components/complementary-area-toggle/index.js index b6690b7df5fc5d..2f8d8dd413674b 100644 --- a/packages/interface/src/components/complementary-area-toggle/index.js +++ b/packages/interface/src/components/complementary-area-toggle/index.js @@ -10,6 +10,25 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { store as interfaceStore } from '../../store'; import complementaryAreaContext from '../complementary-area-context'; +/** + * Whether the role supports checked state. + * + * @param {import('react').AriaRole} role Role. + * @return {boolean} Whether the role supports checked state. + * @see https://www.w3.org/TR/wai-aria-1.1/#aria-checked + */ +function roleSupportsCheckedState( role ) { + return [ + 'checkbox', + 'option', + 'radio', + 'switch', + 'menuitemcheckbox', + 'menuitemradio', + 'treeitem', + ].includes( role ); +} + function ComplementaryAreaToggle( { as = Button, scope, @@ -17,6 +36,7 @@ function ComplementaryAreaToggle( { icon, selectedIcon, name, + shortcut, ...props } ) { const ComponentToUse = as; @@ -26,12 +46,18 @@ function ComplementaryAreaToggle( { identifier, [ identifier, scope ] ); + const { enableComplementaryArea, disableComplementaryArea } = useDispatch( interfaceStore ); + return ( { if ( isSelected ) { disableComplementaryArea( scope ); @@ -39,6 +65,7 @@ function ComplementaryAreaToggle( { enableComplementaryArea( scope, identifier ); } } } + shortcut={ shortcut } { ...props } /> ); diff --git a/packages/interface/src/components/complementary-area/index.js b/packages/interface/src/components/complementary-area/index.js index 363a6ee9dea76c..d9fa8e71acb23a 100644 --- a/packages/interface/src/components/complementary-area/index.js +++ b/packages/interface/src/components/complementary-area/index.js @@ -275,6 +275,7 @@ function ComplementaryArea( { showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } size="compact" + shortcut={ toggleShortcut } /> ) } diff --git a/packages/list-reusable-blocks/src/components/import-dropdown/index.js b/packages/list-reusable-blocks/src/components/import-dropdown/index.js index d20ba9fcf10999..fdad08f80d213c 100644 --- a/packages/list-reusable-blocks/src/components/import-dropdown/index.js +++ b/packages/list-reusable-blocks/src/components/import-dropdown/index.js @@ -17,8 +17,8 @@ function ImportDropdown( { onUpload } ) { contentClassName="list-reusable-blocks-import-dropdown__content" renderToggle={ ( { isOpen, onToggle } ) => (